A concurrency-safe Swift client for the Firmata protocol. Drive an ESP32 (or any Firmata board) from macOS/iOS over Wi-Fi, BLE, TCP, or USB serial — and record tasks that keep running on the board after you disconnect.
The client is an actor: every call is async, and all board→host traffic
arrives on one messages AsyncStream.
| Repo | Role |
|---|---|
| SwiftFirmataClient | This package — the host-side client (macOS 13+ / iOS 16+, Swift 6) |
| SwiftFirmataIR | Optional IR (infrared) add-on package |
| SwiftFirmataSonar | Optional HC-SR04/US-100 distance add-on |
| SwiftFirmataDHT | Optional DHT11/DHT22 climate add-on |
| SwiftFirmataDisplay | Optional SSD1306/SH1106 OLED add-on |
| ESP32FirmataSwift | ESP32 firmware in Embedded Swift (ESP-IDF) |
| ESP32Firmata | The same firmware in C++ (Arduino) |
Both firmwares speak the identical wire protocol — use whichever toolchain you prefer.
.package(url: "https://github.com/doraorak/SwiftFirmataClient.git", from: "17.0.0")Pair with firmware 2.18+ for everything below (the core works on 2.15+).
import SwiftFirmataClient
let client = FirmataClient(transport: BonjourTransport()) // finds the board via mDNS
await client.connect()
try await client.setPinMode(.pin(2), mode: .output)
try await client.digitalWrite(pin: .pin(2), high: true) // LED on
// A task the board runs by itself — forever, even with nobody connected:
try await client.uploadTask(id: 1, repeatEvery: .milliseconds(500)) { board in
board.digitalWrite(pin: .pin(2), high: true)
board.delay(.milliseconds(250))
board.digitalWrite(pin: .pin(2), high: false)
}
await client.disconnect() // it keeps blinking| Transport | When to use it |
|---|---|
BonjourTransport() |
Same LAN — discovers _firmata._tcp via mDNS. Needs NSLocalNetworkUsageDescription + NSBonjourServices in Info.plist. |
TCPTransport(host:port:) |
Known address — static IP, another subnet, VPN, SSH tunnel. No discovery, no Info.plist keys. |
BLETransport() |
No network — Nordic UART Service. Needs NSBluetoothAlwaysUsageDescription. |
SerialTransport(path:) |
USB cable (macOS). The console port; opening it resets the board, so allow a moment before the first query. |
FirmataTransport is a two-requirement protocol (send + openStream) — implement
it to bring your own link. The board serves one master at a time, latest wins; an
evicted client receives an EVICTED notice and its stream ends.
- Pins:
setPinMode,digitalWrite,writeDigitalPort,analogWrite,extendedAnalogWrite,digitalRead,analogRead - Servo:
configureServo(pulse range),servoWrite(0–180° or raw µs) - Streams:
reportDigitalPort/reportAnalogChannel+ themessagesstream;setSamplingInterval - Queries:
queryFirmware,queryProtocolVersion,queryCapabilities,queryAnalogMapping,queryPinState,queryModules - I²C:
configureI2C,i2cWrite,i2cReadOnce,i2cStartReading/i2cStopReading - Internet (the board's Wi-Fi makes the request):
httpGet,httpPost - Registers (shared state with tasks):
setRegister,setFloatRegister,queryRegisters - Wi-Fi provisioning (encrypted X25519 + AES-GCM, over any transport):
provisionWiFi,queryWiFiStatus,forgetWiFi - Tasks:
uploadTask, plus low-levelcreateTask/addToTask/scheduleTask/deleteTask/resetTasks/queryAllTasks/queryTask
Pins and channels are typed — pass .pin(n) / .channel(n), never a bare integer.
uploadTask { board in … } hands you a FirmataTaskRecorder with the same verbs as
the live API, captured as bytes and executed on-device by the scheduler. Tasks
survive disconnects and live in RAM until deleted or reboot. Every recipe is in the
COOKBOOK; the feature set:
- Registers — 32 shared
Int32registers + 16 floats.R0–R15/F0–F7are public (yours, and how tasks share state with each other and the host viasetRegister);R16–R31/F8–F15are internal, where value-producing ops auto-allocate their results so they never clobber your public registers. Pin an explicit public destination withinto: .reg(n). - Branches —
ifTrue(a, .lessThan, b, then: { … }, elseDo: { … }), nestable. - Repeat —
repeat(times: 5, gap: .milliseconds(200)) { … }runs a block exactly N times on-device (a native counted loop, nestable up to 4 deep). - Math —
add/subtract/multiply/divide/modulo+ float variants (÷0 → 0). - Reads —
digitalRead/analogRead;i2cRead(register pointer + up to 4 bytes, packed big-endian). - Internet + JSON —
httpGet/httpPostfrom the task, then inspect the body withjson.getNumber/getFloat/getString/getSize/getType/bodyContains;snapshot/freeretain a body across later requests. - Strings —
string.createString/length/equals/contains/indexOf/toInt/free. - Nested tasks —
addTask(id:) { child in … }uploads and schedules another task with no host involved;deleteTask(id:)stops one. - Telemetry —
sendString(task → host),heapStats.
Optional hardware subsystems sit behind generic primitives — queryModules() to
discover what the connected firmware has, sendToModule(id:payload:) /
FirmataTaskRecorder.moduleOp(id:payload:) to talk to one, and
sendToModuleAwaitingReply(id:payload:) for request/reply ops (e.g. a one-shot sensor
read that answers directly). Each module ships as its own package that depends on this
one, adding typed extensions — import only what you need.
guard try await client.queryModules().contains(where: { $0.name == "ir" }) else { return }| ID | Module | Purpose | Package |
|---|---|---|---|
0x01 |
ir |
Infrared transmit/receive — NEC, RC6, Coolix, or raw; plus capture as text to learn a protocol | SwiftFirmataIR |
0x02 |
sonar |
HC-SR04 / US-100 ultrasonic distance → register | SwiftFirmataSonar |
0x03 |
dht |
DHT11 / DHT22 temperature & humidity → float registers | SwiftFirmataDHT |
0x04 |
display |
SSD1306 / SH1106 OLED — text, registers, strings; 5×7 or compact 4×6 font | SwiftFirmataDisplay |
swift test — 138 tests, no hardware needed: a MockTransport plays the board
(including the provisioning-crypto round-trip), and the recorder's byte output is
golden-tested against captures verified on real hardware.
MIT — see LICENSE.