A modern, concurrency-safe Firmata client for Swift — talk to an Arduino, ESP32, or any Firmata-compatible board from macOS or iOS over Bonjour/TCP or BLE.
Built on Swift’s structured concurrency: the client is an actor, every public API is async, and incoming messages are delivered as an AsyncStream.
Part of a three-repo Firmata-for-ESP32 suite — grab whichever piece you need:
- SwiftFirmataClient — the macOS/iOS Swift client package (this repo).
- ESP32FirmataSwift — Embedded-Swift ESP32 firmware (Wi-Fi/Bonjour + BLE) with the on-device logic + internet extension.
- ESP32Firmata — the C++/Arduino firmware port (same wire protocol).
📖 COOKBOOK.md — copy-paste, in-depth snippets for every client feature (connection, I/O, queries, I2C, internet actions, scheduler tasks, on-device logic).
let client = FirmataClient(transport: BonjourTransport())
await client.connect()
let fw = try await client.queryFirmware()
print("Connected to \(fw.name) v\(fw.major).\(fw.minor)")
try await client.setPinMode(2, mode: .output)
try await client.digitalWrite(pin: 2, high: true) // LED on- Firmata protocol v2.x — protocol/firmware/capability/analog-mapping/pin-state queries, digital & analog I/O, extended analog, PWM, sampling interval, strings, and full I2C.
- Two built-in transports
BonjourTransport— discovers_firmata._tcpservices via mDNS and connects over TCP.BLETransport— connects over the Nordic UART Service (NUS), the de-facto standard for Firmata-over-BLE.
- Bring your own transport — conform to the small
FirmataTransportprotocol to run Firmata over serial, a socket, a mock, or anything else. - Swift 6 / strict concurrency —
Sendablethroughout, no data races,async/awaitend to end. - Tested — a byte-level parser test suite plus integration tests over a mock transport.
| Platforms | macOS 13+, iOS 16+ |
| Toolchain | Swift 6.0+ (Xcode 16+) |
| Transports | BonjourTransport/BLETransport need Network / CoreBluetooth (Apple platforms) |
File ▸ Add Package Dependencies… and enter:
https://github.com/doraorak/SwiftFirmataClient.git
dependencies: [
.package(url: "https://github.com/doraorak/SwiftFirmataClient.git", from: "1.0.0"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "SwiftFirmataClient", package: "SwiftFirmataClient"),
]
),
]The board advertises _firmata._tcp on the local network; the client finds and connects to it automatically.
import SwiftFirmataClient
let client = FirmataClient(transport: BonjourTransport()) // first device found
// let client = FirmataClient(transport: BonjourTransport(named: "esp32-livingroom"))
await client.connect()
let caps = try await client.queryCapabilities() // [[PinCapability]] indexed by pinOn macOS/iOS, add to your Info.plist:
<key>NSLocalNetworkUsageDescription</key>
<string>Discover and control Firmata devices on your network.</string>
<key>NSBonjourServices</key>
<array><string>_firmata._tcp</string></array>let client = FirmataClient(transport: BLETransport()) // first NUS device
// let client = FirmataClient(transport: BLETransport(peripheralName: "Firmata-ESP32"))
await client.connect()Add NSBluetoothAlwaysUsageDescription to your Info.plist.
Every message from the device is published on the messages stream:
try await client.setPinMode(34, mode: .analog)
try await client.reportAnalogPin(0, enable: true) // analog channel A0
Task {
for await message in client.messages {
switch message {
case .analog(let channel, let value): print("A\(channel) = \(value)")
case .digital(let port, let mask): print("port \(port) = \(mask, radix: 2)")
default: break
}
}
}try await client.configureI2C()
try await client.i2cWrite(address: 0x3C, data: [0x00, 0xAE]) // e.g. SSD1306 display-off
let reply = try await client.i2cReadOnce(address: 0x48, registerAddress: 0x00, count: 2)
print(reply.data)The device can store tasks — recorded sequences of Firmata messages with
delays — and run them on its own, even after the client disconnects (Firmata
Scheduler, SysEx 0x7B). Build a task with the recorder, upload it, and leave:
// Blink pin 2 every 250 ms — forever, with no client connected.
try await client.uploadTask(id: 1, repeatEvery: .milliseconds(250)) { board in
board.setPinMode(.pin(2), mode: .output)
board.digitalWrite(pin: .pin(2), high: true)
board.delay(.milliseconds(250))
board.digitalWrite(pin: .pin(2), high: false)
}
await client.disconnect() // the board keeps blinkingstartDelaydelays the first run;repeatEverymakes the task loop with that gap (omit it for a one-shot, which runs once and is then removed).uploadTaskconfirms receipt with a round-trip before returning, so it is safe todisconnect()immediately afterwards.- Tasks live in RAM — a power cycle or
systemReset()clears them. - Low-level control is also available:
createTask,addToTask,scheduleTask,deleteTask,resetTasks,queryAllTasks,queryTask.
An extension carried under the scheduler's reserved
EXTENDED_SCHEDULER_COMMAND(0x7F) — the Scheduler control protocol is unchanged, and a standard Firmata scheduler ignores these ops gracefully (no crash; the conditionals are no-ops). Acted on only by this project's ESP32 firmware (mainbranch). The wire format is documented under Custom protocol below.
A task can also make decisions on the device, so it doesn't just replay a fixed
sequence. The board has 16 global Int32 registers; load values into them and
branch with ifTrue:
// A night-light running entirely on the board, no client connected.
try await client.uploadTask(id: 3, repeatEvery: .milliseconds(1000)) { board in
board.setPinMode(.pin(2), mode: .output)
board.analogRead(into: .reg(0), channel: .channel(0)) // R0 = analog A0
board.ifTrue(.reg(0), .lessThan, .number(300), // dark?
then: { $0.digitalWrite(pin: .pin(2), high: true) }, // LED on
elseDo: { $0.digitalWrite(pin: .pin(2), high: false) }) // else off
}setRegister(_:to:),digitalRead(into:pin:),analogRead(into:channel:). The value-returning reads are typed:digitalRead(pin:)→TaskBool,analogRead(channel:)→TaskNumber.ifTrue(_:_:_:then:elseDo:)— operands.reg(0...15)/.number(value); comparisons== != < > <= >=. Forward-only (no loops), so a task can't hang the board.compare(a, op, b)→ a reusableTaskBool;ifTrue(_ condition: TaskBool) { … }branches on a bool directly (adigitalRead, a JSON predicate, or anisValid()result).channelis an analog channel index (A0 = 0, …), not a pin number.
A task can also reach the internet over the board's Wi-Fi — make an HTTP(S)
request and inspect the response — so the device can talk to web services on its
own, with no host connected. https:// is supported with on-device
certificate validation (a browser-style root-CA bundle).
httpGet returns a TaskHTTPResponse; branch on .status, and pass .body to the
board.json ops to inspect the payload:
// Every minute: green LED if SPY is up on the day, red if it's down — no host.
try await client.uploadTask(id: 5, repeatEvery: .seconds(60)) { board in
board.setPinMode(.pin(2), mode: .output); board.setPinMode(.pin(4), mode: .output)
let spy = board.httpGet("https://example.com/quote/SPY") // -> TaskHTTPResponse
// Pull a fractional JSON number into an Int32 register, scaled (×100):
// -0.42 -> -42 ; path may be dotted/indexed: "result[0].changePercent"
let pct = board.json.getNumber(spy.body, "changePercent", scaledBy: 2)
board.ifTrue(spy.status, .equal, .number(200)) { // only act on success
$0.ifTrue(pct, .greaterThan, .number(0),
then: { $0.digitalWrite(pin: .pin(2), high: true); $0.digitalWrite(pin: .pin(4), high: false) },
elseDo: { $0.digitalWrite(pin: .pin(2), high: false); $0.digitalWrite(pin: .pin(4), high: true) })
}
}httpGet(_:)/httpPost(_:body:)return aTaskHTTPResponse— branch onresp.status(HTTP status;0= Wi-Fi down / failure). Status register auto-allocates (or passstatusInto:). The body is retained for inspection viaresp.body.board.jsonops take the body handle (resp.body, aTaskResponseBody) and return a typed result operand (auto-allocated R15↓ or viainto:). They select the body's source automatically (no manual step):json.getNumber(body, path, scaledBy:)→TaskNumber(number × 10ⁿ, truncated; also parses a quoted number"593.2").json.getFloat(body, path)→TaskFloat.json.bodyContains(body, text)→TaskBool(whole-body substring).json.getString(body, path)→TaskString— captures a string value (into a slot) for theboard.stringops:length/equals/contains/indexOf/toInt.json.getType(body, path)→ aTaskJSONValueTyperaw value — branch before extracting.json.getSize(body, path)sizes a value; pair withboard.heapStats(freeInto:largestInto:)to gate a store on free memory.pathis dotted/indexed:quoteResponse.result[0].regularMarketChangePercent. Inspection walks the full body (no parse-size cap).
- Arithmetic on
board— integeradd/subtract/multiply/divide/modulo(_:_:into:)(→TaskNumber) and floataddFloat/subtractFloat/multiplyFloat/divideFloat(→TaskFloat). 16 int registers + 8 float registers (F0–F7);setFloatRegister(_:to:)loads a literal; operands mix (ints promote). Percent change on-device with floats:let spy = board.httpGet(url) let price = board.json.getFloat(spy.body, "regularMarketPrice") // e.g. 746.74 let prev = board.json.getFloat(spy.body, "chartPreviousClose") let pct = board.multiplyFloat(board.divideFloat(board.subtractFloat(price, prev), prev), .float(100)) // % change board.ifTrue(pct, .greaterThan, .float(0.5)) { … } // up > 0.5%?
- Holding bodies (handles). Each
board.jsonop reads the body handle you pass. To compare two responses, or keep one across the next request,snapshotit (one of 2 grow-only slots) — this upgrades the handle in place to an owned copy that outlives later requests. A borrowed (non-snapshotted) handle goes stale once a newer request replaces the live body; guard it withbody.isValid():let a = board.httpGet(urlA); board.json.snapshot(a.body) // a.body now owns a snapshot let b = board.httpGet(urlB) // a's live body would be stale let aVal = board.json.getNumber(a.body, "price") // from A (snapshot) let bVal = board.json.getNumber(b.body, "price") // from B (live) board.ifTrue(bVal, .greaterThan, aVal) { … } board.json.free(a.body) // staleness: only trust a borrowed read while its body is still current: let c = board.httpGet(urlC) let cVal = board.json.getNumber(c.body, "price") board.ifTrue(c.body.isValid()) { … } // -> compares captured generation vs current
- If a host is connected while the task runs, the full result also arrives as
FirmataMessage.httpResponse(status:body:)on themessagesstream.
You can also fire a request live and inspect it on the host with Foundation:
let r = try await client.httpGet("https://jsonplaceholder.typicode.com/todos/1")
print(r.status, r.isSuccess) // 200 true
struct Todo: Decodable { let id: Int; let title: String; let completed: Bool }
let todo = try r.decode(Todo.self) // typed decode
let any = try r.json() as? [String: Any] // or untyped JSONSerialization
let p = try await client.httpPost("https://example.com/log", body: #"{"on":true}"#)- The device performs the request and blocks briefly (up to ~8 s) while it
does;
httpGet/httpPosttake atimeout(default 15 s). - URL/body must be ASCII and fit one SysEx frame (URL + body ≲ 500 bytes).
- On-device inspection (
jsonNumber/string ops) parses the full body. The copy echoed back to a connected host is capped at ~4 KB (typical JSON responses fit; not for large downloads). https://is certificate-validated on-device against the firmware's bundled root CAs (requires firmware with the TLS client; see the firmware README).
The logic ops are SysEx embedded in a task's data and replayed by the Scheduler,
under SCHEDULER_DATA (0x7B) → EXTENDED_SCHEDULER_COMMAND (0x7F). <const>
is an Int32 packed as 5 Encoder7Bit bytes; <skip> is a 14-bit count,
little-endian 7-bit (skipLo skipHi). <len> fields are 14-bit LE (lo hi);
<path>/<str>/<url>/<body> are raw 7-bit ASCII.
SET F0 7B 7F 10 <reg> <const:5> F7 // R[reg] = const
READ_DIGITAL F0 7B 7F 11 <reg> <pin> F7 // R[reg] = digitalRead(pin)
READ_ANALOG F0 7B 7F 12 <reg> <channel> F7 // R[reg] = analogRead(channel)
IF F0 7B 7F 13 <op> <operandA> <operandB> <skip:2> F7 // if !(A op B): pos += skip
SKIP F0 7B 7F 14 <skip:2> F7 // pos += skip (else)
HTTP F0 7B 7F 15 <method> <statusReg> <urlLen:2> <url…> <bodyLen:2> <body…> F7
JSON_NUM F0 7B 7F 16 <dst> <found> <scale> <pathLen:2> <path…> F7
JSON_STR_EQ F0 7B 7F 17 <dst> <pathLen:2> <path…> <strLen:2> <str…> F7
BODY_CONTAINS F0 7B 7F 18 <dst> <strLen:2> <str…> F7
JSON_STR_CONT F0 7B 7F 19 <dst> <pathLen:2> <path…> <strLen:2> <str…> F7
ARITH F0 7B 7F 1A <subop> <dst> <operandA> <operandB> F7 // R[dst] = A op B (int)
SET_FLOAT F0 7B 7F 1B <fdst> <const:5> F7 // F[fdst] = float
ARITH_F F0 7B 7F 1C <subop> <fdst> <operandA> <operandB> F7 // F[fdst] = A op B (float)
JSON_FLOAT F0 7B 7F 1D <fdst> <found> <pathLen:2> <path…> F7 // F[fdst] = json float
JSON_TYPE F0 7B 7F 1E <dst> <pathLen:2> <path…> F7 // R[dst] = type at path
JSON_SIZE F0 7B 7F 1F <dst> <pathLen:2> <path…> F7 // R[dst] = span byte length
STR_LEN F0 7B 7F 20 <dst> <pathLen:2> <path…> F7 // R[dst] = string content length
HEAP F0 7B 7F 21 <freeReg> <largestReg> F7 // R = free heap / largest block
BODY_GEN F0 7B 7F 22 <dst> F7 // R[dst] = response generation
SNAPSHOT F0 7B 7F 23 <slot> <pathLen:2> <path…> F7 // copy value -> snapshot slot
SELECT F0 7B 7F 24 <sel> <expGenReg> F7 // 0=live(gen-checked), k=snap k-1
FREE F0 7B 7F 25 <slot> F7 // free a snapshot slot
LAST_STATUS F0 7B 7F 26 <dst> F7 // R[dst] = last inspection status
<reg>: int register index, low nibble (0–15).<fdst>: float register (0–7).<op>:0 ==,1 !=,2 <,3 >,4 <=,5 >=.<operand>: a type byte then data —00 <reg>(int register),01 <const:5>(int literal),02 <freg>(float register), or03 <const:5>(float literal, IEEE754 bits).IF/ARITHaccept any type; if either side is float the device promotes.if/elselayout:[IF skip=thenLen] [then…] [SKIP skip=elseLen] [else…]— a falseIFskips the then-block (landing onelse); a true one runsthen, whose trailingSKIPjumps overelse.HTTP(0x15):<method>0=GET1=POST; setsR[statusReg]= HTTP status (0on failure) and retains the body. POST sendsContent-Type: application/json.JSON_NUM(0x16):R[dst]= number at<path>× 10^<scale>(truncated),R[found]=1/0. Also parses a quoted number ("593.2"), so string-typed prices work.JSON_STR_EQ/JSON_STR_CONT(0x17/0x19):R[dst]=1/0from comparing the JSON string at<path>.BODY_CONTAINS(0x18):R[dst]=1/0substring over the whole body. All inspection ops walk the full retained body (no parse-size cap).ARITH(0x1A):<subop>0=+1=−2=×3=÷4=%.R[dst]= A op B (operands as above). 64-bit intermediates avoid overflow;÷/%by zero yield0.- Floats: 8 registers
F0–F7.SET_FLOAT(0x1B) loads a literal;ARITH_F(0x1C, subops0=+1=−2=×3=÷) does float math;JSON_FLOAT(0x1D) reads a JSON number (quoted / fractional / exponent) intoF[fdst],R[found]=1/0. - Query ops:
JSON_TYPE(0x1E) →0none,1object,2array,3string,4number,5bool,6null.JSON_SIZE(0x1F) → span byte length.STR_LEN(0x20) → string content length.HEAP(0x21) → free heap + largest block. - Handles (3b2):
BODY_GEN(0x22) captures the current generation.SNAPSHOT(0x23) copies a value into one of the 12 grow-only slots (2 JSON + 10 string) that survive the next request.SELECT(0x24) chooses the inspection source — a snapshot, or the live body checked against a captured generation (a borrowed source selected after a newer request reads as stale).FREE(0x25) releases a slot.LAST_STATUS(0x26) → status of the last inspection op (0ok,1notFound,2stale,3typeMismatch,4tooBig,5allocFailed). - Compare + strings:
CMP(0x27) →R[dst]=(A <op> B) ? 1 : 0(a reusable boolean register, same operands asIF).JSON_GET_STRING(0x2C) copies a JSON string's content at a path into a snapshot slot (board.json.getString→ aTaskString); theboard.stringops then run on it:STR_BODY_LEN(0x28) → byte length;STR_EQUALS(0x29) → equals;STR_INDEXOF(0x2A) → index, or-1;STR_TO_NUM(0x2B) → leading integer (+ found flag);containsreusesBODY_CONTAINS(0x18).STR_SET_SLOT(0x2D) fills a slot from a literal (board.string.createString);STR_COPY_SLOT(0x2E) copies one string slot into another (TaskString.changeSlot). Slots are typed:TaskJSONSlot(2) andTaskStringSlot(10), 12 device slots total.
The device replies (only when a host is connected) with the result:
HTTP_REPLY F0 7B 0B <status:2> <body 14-bit LSB/MSB pairs…> F7 // device -> host
<status:2> is the HTTP code as lo hi; the body follows as STRING_DATA-style
14-bit pairs. Parsed by the client into FirmataMessage.httpResponse(status:body:).
The base Scheduler messages (CREATE_TASK 0x00, ADD_TO_TASK 0x02,
SCHEDULE_TASK 0x04, DELAY_TASK 0x03, QUERY 0x05/0x06, RESET 0x07)
are unchanged from standard Firmata.
Give a board its Wi-Fi credentials at runtime — typically over BLE, before Wi-Fi is up — so a prebuilt firmware (with placeholder creds) can join your network without a rebuild. The exchange is an ephemeral X25519 ECDH → HKDF-SHA256 → AES-256-GCM, so the password is never sent in the clear (no BLE pairing required); the device persists the creds in NVS and prefers them over the compile-time defaults.
let client = FirmataClient(transport: BLETransport())
await client.connect()
let status = try await client.provisionWiFi(ssid: "MyNetwork", password: "hunter2")
print(status.connected, status.ip ?? "—") // e.g. true 192.168.1.50
// also: queryWiFiStatus(), forgetWiFi()See COOKBOOK.md §22 for details and the security caveat.
await client.disconnect()A FirmataClient owns a single transport/connection for its lifetime — to
switch transports (Bonjour ↔ BLE) or reconnect, disconnect() and make a new
one. A board has a single master; a dual-transport firmware enforces this with
latest-wins (a new connection on either transport evicts the current one).
When you are evicted, messages finishes and lastDisconnectReason tells you
why:
for await _ in client.messages {} // drains until the link ends
switch await client.lastDisconnectReason {
case .replacedByAnotherClient: … // another app/computer took over
case .transportClosed: … // network drop / device reset
case .localRequest, .none: break // you called disconnect()
}This is fully standard-compliant: eviction is signalled with an ordinary
STRING_DATA sentinel, so nothing non-standard goes on the wire.
FirmataClient (actor)
| Group | Methods |
|---|---|
| Lifecycle | connect(), disconnect(), messages (AsyncStream<FirmataMessage>), lastDisconnectReason |
| Digital | setPinMode(_:mode:), digitalWrite(pin:high:), writeDigitalPort(_:pinMask:), reportDigitalPort(_:enable:) |
| Analog | analogWrite(channel:value:), extendedAnalogWrite(pin:value:), reportAnalogPin(_:enable:) |
| Queries | queryProtocolVersion(), queryFirmware(), queryCapabilities(), queryAnalogMapping(), queryPinState(pin:) |
| System | systemReset(), setSamplingInterval(_:), sendString(_:) |
| I2C | configureI2C(delay:), i2cWrite(address:data:), i2cReadOnce(address:registerAddress:count:), i2cStartReading(...), i2cStopReading(address:) |
| Live reads | digitalRead(pin:timeout:) -> Bool, analogRead(channel:timeout:) -> UInt16 |
| Scheduler | uploadTask(id:startDelay:repeatEvery:_:), createTask(id:length:), addToTask(id:data:), scheduleTask(id:delay:), deleteTask(id:), resetTasks(), queryAllTasks(), queryTask(id:) |
| Extension¹ — internet | httpGet(_:timeout:) -> HTTPResponse, httpPost(_:body:timeout:) -> HTTPResponse |
| Extension¹ — Wi-Fi provisioning | provisionWiFi(ssid:password:timeout:) -> WiFiStatus, queryWiFiStatus(timeout:), forgetWiFi(timeout:) (encrypted over BLE — see COOKBOOK §22) |
FirmataTaskRecorder (used inside uploadTask) mirrors the writes — setPinMode,
digitalWrite(pin:high:), analogWrite(channel:value:), delay(_:), plus
I2C configureI2C(delay:) / i2cWrite(address:data:is10Bit:) (drive an
I2C device — e.g. an SSD1306 OLED — from a task) — plus the
extension¹ ops: setRegister, digitalRead(into:)/digitalRead(pin:),
analogRead(into:)/analogRead(channel:), ifTrue(_:_:_:then:elseDo:),
ifTrue(_:then:elseDo:) (bool operand), compare(_:_:_:into:),
httpGet(_:) -> TaskHTTPResponse, httpPost(_:body:) -> TaskHTTPResponse,
integer arithmetic add/subtract/multiply/divide/modulo(_:_:into:),
float setFloatRegister(_:to:) / addFloat/subtractFloat/multiplyFloat/divideFloat(_:_:into:),
heapStats(freeInto:largestInto:), and the board.json namespace —
number/float/bodyContains/getString/type/size
(each taking a resp.body handle), plus snapshot(_:into:) (in-place) / free(_:), with
borrowed-handle freshness via body.isValid().
¹ Non-standard extension — requires supported firmware. The on-device logic (registers /
if-else) and internet actions are not part of standard Firmata. They ride under the Scheduler's reservedEXTENDED_SCHEDULER_COMMAND(0x7F) so a stock Firmata board ignores them harmlessly, but they only do anything on firmware that implements this project's extension — see Firmware.
Custom transports conform to:
public protocol FirmataTransport: Sendable {
func send(_ bytes: [UInt8]) async throws
func openStream() -> AsyncThrowingStream<UInt8, Error>
}Two tiers of functionality:
-
Standard Firmata (any firmware). Pin I/O, queries, I2C, the base Scheduler, strings, sampling — these work with any standard Firmata firmware (StandardFirmata / ConfigurableFirmata) over whichever transport you supply. The client speaks Firmata protocol v2.x.
-
Extension features (require this project's firmware). The on-device logic extension (16 registers,
digitalRead/analogRead,ifTrue/compare) and internet actions (httpGet/httpPost, live and in tasks) are a non-standard extension. They are carried under the Scheduler's reservedEXTENDED_SCHEDULER_COMMAND(0x7F), so a stock Firmata board ignores them without error — but nothing happens unless the firmware implements the extension (and, for internet actions, has Wi-Fi). CallinghttpGetagainst unsupported firmware simply times out; a logic task degrades to its no-op parts.
Firmware that supports the extension (both implement the logic extension, internet actions, and the JSON/string response-inspection ops — same wire format):
- ESP32FirmataSwift — an Embedded-Swift firmware for the original ESP32.
- ESP32Firmata — the Arduino/C++ sketch (Wi-Fi/TCP + Bonjour and BLE in one build; uses NimBLE).
Both do https:// (cert-validated) alongside Wi-Fi + BLE, plus the JSON/string
inspection ops, with the same wire format.
For the built-in transports the device should:
BonjourTransport— advertise_firmata._tcpon the local network (ideally withipandportTXT records, which let the client skip mDNS A-record resolution).BLETransport— expose the Nordic UART Service (6E400001-B5A3-F393-E0A9-E50E24DCCA9E), RX = write, TX = notify.
swift testReleased under the MIT License. See LICENSE.