Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Pico de Gallo turns a Raspberry Pi Pico 2 into a USB-attached protocol bridge so you can drive I²C, SPI, UART, GPIO, PWM, ADC, and 1-Wire peripherals from a host PC — and, more importantly, so you can develop and test embedded device drivers on your laptop, with no flashing, no SWD probe, no clock or pin-mux setup, and no linker scripts in your way.

If you’ve ever wanted to:

  • prototype a driver against embedded-hal traits without booting an MCU each time you change a line,
  • script a sensor or actuator from Python or a shell one-liner,
  • write an integration test that talks to real silicon from your CI runner,

then Pico de Gallo is for you.

What’s in the Box

A small landing-board PCB with castellated pads for the Pico 2, a firmware image that speaks postcard-rpc over USB, and a small constellation of host-side crates:

  • gallo — a CLI for one-off and scripted access to every interface.
  • pico-de-gallo-lib — an async Rust client built on nusb and tokio.
  • pico-de-gallo-hal — a host-side embedded-hal / embedded-hal-async shim so existing drivers work unchanged.
  • pico-de-gallo-ffi — a C shared library with a stable opaque-pointer API.
  • pyco-de-gallo — Python bindings via PyO3 and maturin.
Pico de Gallo rev1
Pico de Gallo v1.0
Pico de Gallo rev1.1
Pico de Gallo v1.1

Hardware Revisions at a Glance

RevisionFirmware featureConnectorCapabilities
v1.0hw-rev1 (deprecated)seven pin headersI²C, SPI, GPIO, PWM
v1.1hw-rev2 (default)one keyed 2×12 boxI²C, SPI, UART, GPIO, PWM, ADC, 1-Wire
v2 (WIP)hw-rev2 (default)2×12 box + level Txsame as v1.1, plus variable VREF

You’ll want v1.1 or later if you need UART, ADC, or 1-Wire. hw-rev2 is the default firmware feature; hw-rev1 is deprecated but still supported, with removal no earlier than 2031-09-01. See Revisions: v1.0 vs v1.1.

How to Read This Book

The book is laid out as six parts plus appendices:

  1. The Hardware — what’s on the PCB, which revision to pick, how to flash firmware.
  2. Getting Started — install the toolchain and verify your device.
  3. The Interfaces — one chapter per peripheral. Each follows the same template: overview, pin mapping, CLI usage, Rust, C, Python, error handling.
  4. The Crates — reference for each crate in the workspace.
  5. Writing a Device Driver — the longest part. A TMP102 walkthrough showing the dev loop, the blocking/async parity, and how to write hardware-in-the-loop tests that run on your laptop.
  6. Under the Hood — the wire protocol, schema versioning, firmware architecture, and the release cadence.

Read top-to-bottom for a tour, or jump straight to the chapter you need — each chapter stands on its own.

The Optional Case

A 3D-printable, snap-fit enclosure lives in the case/ folder of the repository if you want to keep the board safe on your bench.

3D printable case

Hardware Overview

Pico de Gallo rev1
v1.0 — seven pin headers
Pico de Gallo rev1.1
v1.1 — keyed 2×12 box header

Pico de Gallo is a small landing-board PCB designed to host a Raspberry Pi Pico 2 module via castellated edge pads. The landing board exists for one reason: to make the pin-to-function mapping predictable and labeled, so the firmware always knows where to look for SDA, SCK, UART TX, and friends, and so you don’t have to keep a pinout chart taped to your monitor.

Everything Pico de Gallo can do, a bare Pico 2 with the same firmware can also do — but the landing board adds:

  • silkscreened labels for every signal,
  • pull-ups for I²C (4.7 kΩ on v1.1+),
  • series resistors on ADC inputs (100 Ω on v1.1+),
  • decoupling on VREF,
  • and a keyed connector so cables only go in one way (v1.1+).

The Pico 2 itself supplies the RP2350 MCU, the USB connector, the BOOTSEL button, and the 3.3 V regulator. Pico de Gallo just brings the right signals to the right places.

Block Diagram

        ┌──────────────────────────────────────────┐
        │              Pico de Gallo PCB           │
        │                                          │
USB ────│──► Pico 2 (RP2350) ──► castellated pads ─┼──► I²C / SPI
        │        │                  │              │      UART / GPIO
        │        │                  │              │      PWM / ADC
        │        │                  │              │      1-Wire
        │        │                  ▼              │
        │        │     pull-ups, series R,         │
        │        │     decoupling, header(s)       │
        │        ▼                                 │
        │  defmt RTT (debug)                       │
        └──────────────────────────────────────────┘

What’s on the PCB

Componentv1.0v1.1+Purpose
Pico 2 pads✓✓Castellated landing for the MCU
I²C pull-upsexternal4.7 kΩRequired for I²C operation
ADC series R—100 ΩInput protection / RC filter
VREF decoupling—100 nFStabilises ADC reference
Pin headers7×—Per-bus 0.1″ pin headers
Box header—1× 2×12Single keyed shrouded connector
BOOTSEL buttonon Picoon PicoBoot to UF2 mass-storage mode

The Three Ways to Get a Board

  1. Order a fabricated PCB from any house that accepts gerbers (JLCPCB, PCBWay, OSH Park, Aisler, …). Our gerbers are on the Releases page under hardware-v* tags. Most houses will also assemble the board if you upload the BOM and pick-and-place files; this is the easiest path and we recommend it.
  2. Hand-solder the Pico 2 and headers yourself. The board has no fine-pitch components — it’s a comfortable first SMT-ish project. See Assembly & Flashing.
  3. Skip the board entirely and wire a bare Pico 2 directly, matching the pinout in Pinout & Connector. The firmware doesn’t care whether the signals come from a landing board or a breadboard.

What’s Next

Revisions: v1.0 vs v1.1

The Pico de Gallo PCB has shipped in two revisions, with a third planned. The revision determines two things:

  1. Which firmware feature flag you flash (hw-rev1 or hw-rev2).
  2. Which peripherals the firmware exposes.
RevisionFeature flagConnectorCapabilities
v1.0hw-rev1 (deprecated)7 separate pin headersI²C, SPI, GPIO, PWM
v1.1hw-rev2 (default)one keyed 2×12 shrouded headerI²C, SPI, UART, GPIO, PWM, ADC, 1-Wire
v2 (future)hw-rev2 (default)2×12 shrouded header + level translatorssame as v1.1, plus variable VREF rail

Important

The capability set is enforced by firmware, not by the hardware. Flashing hw-rev1 firmware onto a v1.1 board still gives you only the v1.0 capability set. Match the firmware to the board you actually have.

hw-rev1 Is Deprecated

hw-rev2 is the default firmware feature. Building the firmware with no feature flags produces a rev2 image:

$ cargo build --release --target thumbv8m.main-none-eabihf

hw-rev1 is deprecated and must now be requested explicitly:

$ cargo build --release --target thumbv8m.main-none-eabihf \
    --no-default-features --features hw-rev1

Deprecated does not mean unsupported:

  • rev1 boards keep working. hw-rev1 is still built and linted in CI on every push, and firmware-rev1.uf2 is still attached to every firmware release.
  • Removal will not happen before 2031-09-01. That is a floor — five years’ notice — not a commitment to remove on that date.

Two signals fire when you enable it. The build script prints a cargo:warning naming the removal floor, and the firmware logs a defmt::warn! over RTT on every boot, so a rev1 image is identifiable from the device as well as from the build log.

If you are fabricating a board today, fabricate v1.1 and use the default hw-rev2 firmware.

Which One Should I Pick?

  • You only need I²C, SPI, GPIO, or PWM → an existing v1.0 board is fine and keeps working, but hw-rev1 is deprecated (see above); don’t fabricate a new one.
  • You need UART, ADC, or 1-Wire → you want v1.1 (or later). Calling an unsupported endpoint on v1.0 firmware returns Unsupported.
  • You’re starting from scratch today → fabricate v1.1. It’s the same effort and a strict superset.

What v1.1 Adds Over v1.0

Pico de Gallo v1.1

  • Single keyed box header. One cable, one orientation, no ambiguity. Adapter boards (informally called “toppings”) can be designed against a stable connector instead of wrangling seven separate jumper bundles.
  • All implemented peripheral signals routed. UART, 1-Wire, and the three ADC inputs are physically absent on v1.0. v1.1 also routes the PCB’s SPI_CS net from RP2350 GPIO 5, but that net is a label only — current firmware never claims or drives that pad.
  • On-board passives.
    • 4.7 kΩ pull-ups on I²C (no more dangling resistors).
    • 100 Ω series resistors on each ADC input for protection and a mild RC roll-off.
    • 100 nF decoupling on VREF.

What v2 Will Add

Planned but not yet released:

  • Variable VREF rail on header pin 1: selectable 1.8 V / 3.3 V / 5 V. On v1.1 this pin is hardwired to 3.3 V.
  • Level translators on the digital signals so the same board can talk to 1.8 V, 3.3 V, and 5 V peripherals without an external level shifter.
  • Same firmware feature flag as v1.1 (hw-rev2).

Note

Adapter boards designed for the v1.1 box-header pinout will plug into v2 unchanged. On v1.1 they will see 3.3 V on pin 1; on v2 they will see whatever VREF is set to. Design your topping with that in mind.

v1.0 — Best Effort

Pico de Gallo v1.0

The v1.0 board predates the consolidated header and lacks routing for UART (GPIO 0-1), 1-Wire (GPIO 16), and ADC (GPIO 26-28). Firmware validates those endpoints and returns Unsupported, so calling them won’t crash — you just won’t get data.

If you’re stuck on v1.0 and need one of the missing signals, you can solder a wire directly to the corresponding Pico 2 castellated pad. It’s not pretty, but it works for UART, 1-Wire, and ADC.

SPI_CS is different. Its physical GPIO 5 net is present on the v1.1 header but absent on v1.0, and current firmware does not claim or drive GPIO 5 on either revision. Soldering to that pad does not make it usable as firmware-controlled chip-select; use a user GPIO index reported by device/info instead.

Note that SPI_CS on the v1.1 silkscreen and in the KiCad schematic is a net label, not a feature. Neither revision provides a working dedicated chip-select signal, and the misleading label can only be retired by a future board revision. See issue #99.

Identifying Your Board

The fastest way to tell what revision firmware you’re running:

$ gallo version
╭─────────────┬──────────────────────────────╮
│ Firmware    │ v0.12.0                      │
│ Schema      │ v0.8.0                       │
│ HW revision │ 2                            │
│ GPIOs       │ 4                            │
│ Build       │ firmware-v0.12.0-42-g1a2b3c4 │
╰─────────────┴──────────────────────────────╯
╭─────┬─────┬──────┬──────┬─────┬─────┬────────╮
│ I2C │ SPI │ UART │ GPIO │ PWM │ ADC │ 1-Wire │
├─────┼─────┼──────┼──────┼─────┼─────┼────────┤
│ ✓   │ ✓   │ ✓    │ ✓    │ ✓   │ ✓   │ ✓      │
╰─────┴─────┴──────┴──────┴─────┴─────┴────────╯

HW revision 1 corresponds to hw-rev1; 2 corresponds to hw-rev2. The capability table tells you exactly which peripherals this firmware will serve.

Migrating from v1.0 to v1.1

Code-wise, nothing changes. The wire protocol is the same; the host crates are the same; the CLI is the same. The only thing that moves is which physical pins your peripheral cables plug into — see Pinout & Connector.

If you have driver code that detects capabilities at runtime, use device_info() (host) / gallo_get_device_info (FFI) and gate on the capabilities bitfield. That way the same binary works unmodified on both boards.

Pinout & Connector

This is the authoritative pin map for both PCB revisions. Refer to it whenever you wire up a peripheral.

Firmware Pin Map

The firmware always uses the same RP2350 GPIOs, regardless of which revision PCB they’re routed to.

FunctionRP2350 GPIOAvailable onNotes
UART TXGPIO 0v1.1+UART0 TX, buffered
UART RXGPIO 1v1.1+UART0 RX
I²C SDAGPIO 2v1.0+I²C1, async DMA
I²C SCLGPIO 3v1.0+
SPI RX (MISO)GPIO 4v1.0+SPI0, DMA full-duplex
SPI_CS netGPIO 5v1.1+PCB net name only; firmware never claims or drives it
SPI SCKGPIO 6v1.0+
SPI TX (MOSI)GPIO 7v1.0+
GPIO 0GPIO 8v1.0+User pin, in/out/edge
GPIO 1GPIO 9v1.0+User pin, in/out/edge
GPIO 2GPIO 10v1.0+User pin, in/out/edge
GPIO 3GPIO 11v1.0+User pin, in/out/edge
PWM 0GPIO 12v1.0+Slice 6 channel A
PWM 1GPIO 13v1.0+Slice 6 channel B
PWM 2GPIO 14v1.0+Slice 7 channel A
PWM 3GPIO 15v1.0+Slice 7 channel B
1-WireGPIO 16v1.1+PIO0/SM0, open-drain
ADC 0GPIO 26v1.1+12-bit, 0–3.3 V nominal
ADC 1GPIO 27v1.1+12-bit
ADC 2GPIO 28v1.1+12-bit

The user-facing GPIO numbering in the CLI, library, FFI, and Python bindings (0–3) maps to RP2350 GPIO 8–11. Same goes for ADC channels (0–2 → GPIO 26–28) and PWM channels (0–3 → GPIO 12–15).

v1.0 Pin Headers

v1.0 uses seven separate 0.1″ pin headers, one per logical bus. Refer to the silkscreen on the board for the exact layout. Signals not brought out on v1.0: UART TX/RX, 1-Wire, and ADC 0–2. The physical SPI_CS net to GPIO 5 is also absent; on v1.1 it is routed to the header, but current firmware does not drive it.

v1.1 Box Header

v1.1 consolidates everything onto a single keyed 2×12 (0.1″ pitch) shrouded box header. Viewed from above with the USB connector pointing up, pin 1 is at the top-right. The shroud key notch faces right. Even-numbered pins (bottom row) are on the left; odd-numbered pins (top row) are on the right.

 Pin 2  GND          ┃ VREF (+3V3) Pin 1
 Pin 4  I2C_SCL      ┃ I2C_SDA     Pin 3
 Pin 6  SPI_MOSI     ┃ SPI_MISO    Pin 5
 Pin 8  SPI_CS *      ┃ SPI_SCK     Pin 7
 Pin 10 UART_RX      ┃ UART_TX     Pin 9
 Pin 12 GPIO1        ┃ GPIO0       Pin 11
 Pin 14 GPIO3        ┃ GPIO2       Pin 13
 Pin 16 PWM1         ┃ PWM0        Pin 15
 Pin 18 PWM3         ┃ PWM2        Pin 17
 Pin 20 ADC0         ┃ ONEWIRE     Pin 19
 Pin 22 ADC2         ┃ ADC1        Pin 21
 Pin 24 GND          ┃ +3V3        Pin 23

* The silkscreen and schematic label pin 8 SPI_CS, but that name describes the PCB net only. No firmware claims GPIO 5, so the pin is not a working chip select — see below.

Full v1.1 Pinout Table

Header PinNetRP2350 GPIODirectionNotes
1VREF—Power out3.3 V (hardwired on v1.1)
2GND—PowerGround
3SDAGPIO 2BidirI²C1 SDA, 4.7 kΩ pull-up
4SCLGPIO 3BidirI²C1 SCL, 4.7 kΩ pull-up
5SPI_MISOGPIO 4InputSPI0 RX
6SPI_MOSIGPIO 7OutputSPI0 TX
7SPI_SCKGPIO 6OutputSPI0 SCK
8SPI_CS (unused)GPIO 5Not driven by firmwarePCB net name only; GPIO 5 remains in its reset state
9UART_TXGPIO 0OutputUART0 TX
10UART_RXGPIO 1InputUART0 RX
11GPIO0GPIO 8BidirUser GPIO 0
12GPIO1GPIO 9BidirUser GPIO 1
13GPIO2GPIO 10BidirUser GPIO 2
14GPIO3GPIO 11BidirUser GPIO 3
15PWM0GPIO 12OutputPWM slice 6A
16PWM1GPIO 13OutputPWM slice 6B
17PWM2GPIO 14OutputPWM slice 7A
18PWM3GPIO 15OutputPWM slice 7B
19ONEWIREGPIO 16BidirPIO0/SM0, open-drain
20ADC0GPIO 26InputVia 100 Ω series resistor
21ADC1GPIO 27InputVia 100 Ω series resistor
22ADC2GPIO 28InputVia 100 Ω series resistor
23+3V3—Power outDirect 3.3 V
24GND—PowerGround

Header pin 8 carries the PCB’s SPI_CS net to RP2350 GPIO 5, but current firmware never claims or drives that pad. It is not a valid spi/batch chip-select index or user GPIO index. Use firmware GPIO indices 0–3, which map to RP2350 GPIO 8–11, for chip-select instead. No pull or stable inactive level is guaranteed on GPIO 5.

The SPI_CS name is a silkscreen and schematic net label, not a capability. It records what the net was routed for, not what the firmware does with it, and the board offers no working dedicated chip-select signal. The label cannot be corrected without a new board revision, so treat this page — not the silkscreen — as authoritative. See issue #99.

The device currently reports num_gpios == 4, so host validation refuses --cs 5 before transmitting anything. If that validation is bypassed, firmware returns SpiError::InvalidCsPin without touching a pin.

Note

Pin 1 (VREF) is hardwired to 3.3 V on v1.1. On the future v2 board it becomes a switchable rail (1.8 V / 3.3 V / 5 V). Adapter boards designed today against the v1.1 header will see 3.3 V; they’ll continue to plug into v2 with the same key orientation.

Electrical Notes

  • All digital I/O is 3.3 V CMOS. Do not drive 5 V signals directly into Pico de Gallo without a level translator.
  • The on-board I²C pull-ups (v1.1+) are sized for moderate bus capacitance. For long cables or many devices, add external pull-ups in parallel and treat the on-board value as a minimum.
  • The ADC inputs see a 100 Ω series resistor on v1.1+. Keep that in mind for source-impedance budgeting if you care about absolute accuracy.
  • 3.3 V and VREF on v1.1 share the Pico 2’s regulator. Don’t pull hundreds of milliamps from the header.

Assembly & Flashing

Getting a working Pico de Gallo on your desk takes two steps:

  1. Get a populated PCB.
  2. Flash the firmware.

Step 1 has three options, ordered from easiest to most hands-on. Step 2 is the same regardless of how you got the board.

Step 1: Get a Populated PCB

Option A — Have the PCB house assemble it

Most modern PCB fabrication services (JLCPCB, PCBWay, OSH Park with their assembly partners, Aisler, etc.) will both fabricate and assemble the board for you. Upload the gerbers, BOM, and pick-and-place files from the hardware-v* release, choose your solder mask color, and a fully-built board arrives at your door. This is the recommended path — it’s cheap at small quantities, and your time is worth more than the assembly fee.

Note

Pico de Gallo PCB assembly is not affiliated with any specific PCB house. Any cost, mistake, or damage associated with PCB fabrication and assembly is your responsibility.

Option B — Fabricate bare, solder yourself

If you’d rather solder, the board uses through-hole and medium-pitch components only — there’s nothing exotic.

Order of operations:

  1. Solder the Pico 2 first. It’s the lowest component on the board. Tack one corner pad, check alignment, tack the opposite corner, then run a bead along all remaining pads. A bit of no-clean flux makes this much easier — solder follows flux onto exposed copper.
  2. Right-angle headers next. Hold them in place with a piece of polyimide (“Kapton”) tape or a third hand, tack one pin, verify the header sits flush, then solder the rest.
  3. Straight headers last. Same approach — one pin first, check alignment, finish the rest.
  4. Clean off the flux with 99% IPA and an ESD-safe brush in a well-ventilated area.

Caution

Isopropyl alcohol is flammable. Don’t smoke or have open flames near it. Use it in a well-ventilated area.

After cleanup, eyeball the board for solder bridges between adjacent pins before applying USB power.

Option C — Skip the PCB, wire a bare Pico 2

The firmware works on a bare Pico 2 too. Wire your peripherals directly to the RP2350 GPIOs listed in Pinout & Connector. You’ll need to provide your own I²C pull-ups (4.7 kΩ to 3.3 V on SDA and SCL) if you want I²C to work.

Step 2: Flash the Firmware

The Pico 2 ships with a built-in UF2 bootloader, so you don’t need a programmer, a debug probe, or any extra software. Just a USB cable.

  1. Download the latest firmware.uf2 from the Releases page (look for a tag like firmware-v0.8.0). Pick the build that matches your board revision:
    • firmware-rev2.uf2 for the v1.1 board — this is the default
    • firmware-rev1.uf2 for the v1.0 board — hw-rev1 is deprecated but still built and published; removal not before 2031-09-01
  2. With the Pico 2 unplugged, press and hold the BOOTSEL button on top of the module.
  3. Plug the USB cable in while still holding BOOTSEL, then release.
  4. A USB mass-storage drive named RP2350 appears on your host.
  5. Drag-and-drop the firmware.uf2 onto that drive (or cp/Copy-Item from a shell).
  6. The drive vanishes; the Pico 2 reboots into the new firmware automatically.

That’s it — no command-line flashing tool required.

Tip

If the RP2350 drive doesn’t show up, the Pico 2 didn’t enter bootloader mode. Unplug, hold BOOTSEL, plug back in. Don’t release BOOTSEL until you see the drive.

Step 3: Verify

Confirm the firmware is alive by running gallo version. See Verifying Your Device for the expected output and what each field means.

When Things Go Wrong

  • Drive doesn’t appear in BOOTSEL mode — try a different USB cable (some “charge-only” cables don’t carry data) or a different USB port.
  • gallo can’t find the device after flashing — on Linux you may need a udev rule; on Windows the WinUSB driver may need to be installed via Zadig. See USB & OS Notes.
  • You flashed hw-rev1 onto a v1.1 board (or vice versa) — no damage done; just re-enter BOOTSEL and flash the right build.

Installing the Toolchain

To use Pico de Gallo from a host PC, you need the gallo command-line tool. That’s it — gallo will speak to the firmware over USB and you don’t need any extra drivers on most platforms.

There are two ways to get it: pre-built binaries (fastest), or building from source.

Option A — Pre-built Binaries

Pre-built binaries are attached to every application-v* release on the Releases page. Supported triples:

OSArchitectures
Linuxx86_64, aarch64
Windowsx86_64, aarch64
macOSaarch64

Download the right archive for your system, unzip, and put gallo (or gallo.exe) somewhere on your PATH.

$ gallo --version
gallo 0.8.0

Option B — Build from Source

If your platform isn’t in the table above, or you want to live on main:

  1. Install Rust (stable toolchain, 1.90 or newer — the workspace pins MSRV to 1.90).
  2. Clone the repo:
    $ git clone https://github.com/OpenDevicePartnership/pico-de-gallo
    $ cd pico-de-gallo/crates
    
  3. Build the CLI:
    $ cargo build --release -p gallo
    
  4. The binary lives at target/release/gallo (or gallo.exe on Windows). Move or symlink it into a directory on your PATH.

Tip

On Linux you may want to install the libudev headers first so nusb builds without extra steps:

$ sudo apt install libudev-dev pkg-config

Optional Extras

You only need these if you’re working on Pico de Gallo, not just with it:

  • The mdBook source for this book lives under book/. Build with mdbook build book.
  • The C FFI library (pico-de-gallo-ffi) builds a .so / .dylib / .dll shared library plus a generated pico_de_gallo.h header. See crates/ffi.md.
  • The Python bindings (pyco-de-gallo) build with maturin:
    $ pip install maturin
    $ cd crates/pyco-de-gallo
    $ maturin develop --release
    
    See crates/python.md.

Next

Now verify your device is talking to the host.

Verifying Your Device

Plug the freshly-flashed Pico de Gallo into your host and run:

$ gallo version
╭─────────────┬──────────────────────────────╮
│ Firmware    │ v0.12.0                      │
│ Schema      │ v0.8.0                       │
│ HW revision │ 2                            │
│ GPIOs       │ 4                            │
│ Build       │ firmware-v0.12.0-42-g1a2b3c4 │
╰─────────────┴──────────────────────────────╯
╭─────┬─────┬──────┬──────┬─────┬─────┬────────╮
│ I2C │ SPI │ UART │ GPIO │ PWM │ ADC │ 1-Wire │
├─────┼─────┼──────┼──────┼─────┼─────┼────────┤
│ ✓   │ ✓   │ ✓    │ ✓    │ ✓   │ ✓   │ ✓      │
╰─────┴─────┴──────┴──────┴─────┴─────┴────────╯

If you see that block, you’re done. Success 🎉.

What Each Field Means

FieldWhat it tells you
FirmwareThe firmware semver. Lockstepped with the wire crate at release time.
SchemaThe wire-protocol schema version. The host crate must understand this.
HW revision1 if you flashed hw-rev1 firmware, 2 for hw-rev2.
GPIOsNumber of user-controllable GPIO pins this firmware exposes. This is the authoritative bound for a GPIO index and for an SPI chip-select pin.
BuildFirmware build identity from git describe. A trailing -dirty means the image was built from a modified working tree. Informational only — it never affects whether a command succeeds. Quote this when reporting a bug or reproducing a measurement.
Capability tableWhich peripherals this firmware build exposes. A ✗ means the endpoint returns Unsupported.

Important

The HW revision line reflects the firmware build, not the PCB you have. If you flashed the wrong build, re-enter BOOTSEL and flash the right one. See Assembly & Flashing.

Ping

For a quick round-trip sanity check:

$ gallo ping
Ping OK

ping sends a random u32 to the firmware and asserts the echo matches. If you can ping, USB and the wire protocol are fully functional.

The payload is randomised per invocation, so a stale or default-initialised response can’t pass as a healthy round trip. If the round trip completes but the value comes back wrong, you get the mismatch instead of a generic transport error:

$ gallo ping
Error: ping echo mismatch: sent 0x9f2c41ab, received 0x00000000

Note

ping and version are the only device subcommands that skip the up-front schema check described below. That is deliberate: a board whose schema doesn’t match your gallo build should still be able to prove its USB path works.

Listing Multiple Devices

If you have more than one Pico de Gallo connected, gallo list shows them:

$ gallo list
Serial Number         Bus    Address
E6633861A34B8C24      2      14
E6633861A34B9F17      1      8

Pick a specific device with -s (or --serial-number):

$ gallo -s E6633861A34B8C24 version

Without -s, gallo uses the first device it finds — which is non-deterministic if you have more than one plugged in.

Schema Mismatch

If the firmware and your host CLI disagree on the wire protocol, gallo version will tell you:

$ gallo version
Error: schema mismatch (firmware v0.5.0, host expects v0.4.x)

The fix is to update whichever side is behind. See Releases & Compatibility for the rules on which versions are compatible.

Next

You’re up and running. Pick an interface to play with — start with I²C or GPIO, or skip to Writing a Device Driver for the guided tour.

USB & OS Notes

The Pico de Gallo firmware uses a generic WinUSB-compatible descriptor, so most operating systems pick it up without a custom driver. The notes below cover the cases where you need to nudge the OS.

Linux

Out-of-the-box, libusb (and therefore nusb) requires root to open arbitrary USB devices. To let your regular user account talk to Pico de Gallo, drop a udev rule:

# /etc/udev/rules.d/99-pico-de-gallo.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", ATTR{idProduct}=="067d", MODE="0666"

Then reload udev:

$ sudo udevadm control --reload-rules
$ sudo udevadm trigger

Unplug and replug the device. gallo version should now work without sudo.

Note

The device enumerates with VID 045e (Microsoft) and PID 067d, defined by the firmware (MICROSOFT_VID / PICO_DE_GALLO_PID in pico-de-gallo-internal). They may change across firmware versions, so avoid hard-coding them in long-lived tooling.

Windows

The firmware advertises a Microsoft OS 2.0 descriptor that tells Windows to bind the WinUSB driver automatically. The first time you plug in a Pico de Gallo, you may see a brief “installing device” notification — that’s normal. After that, gallo works without any extra setup.

If for some reason WinUSB doesn’t bind (e.g., a stale Zadig override, or driver-signing policy on a corporate machine), use Zadig to manually install the WinUSB driver against the Pico de Gallo interface.

Important

Zadig lists two Pico de Gallo interfaces, because the device exposes a second vendor-class interface to carry its WebUSB descriptor. Bind WinUSB to interface 0 — the one with the bulk endpoints. Interface 1 has no endpoints, and binding it leaves gallo unable to reach the device.

macOS

No extra setup. macOS picks the device up automatically.

If gallo list returns nothing, check System Information → USB and confirm the device enumerates. If it shows up there but gallo can’t find it, you might have a code-signing issue with a locally-built gallo binary — try the pre-built release artifact.

Browsers (WebUSB)

The firmware advertises a WebUSB platform capability in its BOS descriptor, along with a landing-page URL. Chrome and Edge use this to show a notification pointing at https://balbi.sh/pico-de-gallo/ when you plug the device in.

Two browser requirements are worth knowing up front, because neither is something the firmware can influence:

  • Secure context. navigator.usb only exists on pages served over HTTPS, or on localhost.
  • User gesture. navigator.usb.requestDevice() must be called from a click or keypress handler. There is no way to connect automatically on page load; the user picks the device from a browser dialog every time a new origin asks.
const device = await navigator.usb.requestDevice({
  filters: [{ vendorId: 0x045e, productId: 0x067d }],
});
await device.open();
// The OS does not always configure the device during enumeration.
if (device.configuration === null) await device.selectConfiguration(1);
await device.claimInterface(0);

Interface 0 is the vendor-specific interface carrying the two bulk endpoints that the RPC protocol uses. The device also exposes a second, endpoint-less vendor interface that exists only to carry the WebUSB capability descriptor — do not claim it.

Note

These descriptors are a convenience, not a gate. WebUSB can talk to any vendor-class device, so a browser could already reach Pico de Gallo without them. What they add is the landing-page notification and explicit signalling that the device is WebUSB-aware.

The same OS-level permissions still apply: a Linux udev rule is required (see above), and on Windows the device must be bound to WinUSB, which the Microsoft OS 2.0 descriptor handles automatically.

Troubleshooting

  • gallo: device not found — Is the device plugged in? Did you flash firmware? Try gallo list.
  • Permission denied on Linux — udev rule missing or not reloaded. See above.
  • gallo version succeeds but gallo i2c scan hangs — the bus has no pull-ups, or your peripheral is clock-stretching forever. Add 4.7 kΩ pull-ups (v1.0 boards lack them on-board).
  • Device disappears after a write — likely a brown-out from trying to source too much current through the on-board 3.3 V rail. Power the peripheral externally.

See also: Troubleshooting for the full list.

I²C

Pico de Gallo provides a single I²C bus on the RP2350’s hardware I²C1 controller. SDA is on GPIO 2 and SCL on GPIO 3. The v1.1 PCB includes on-board 4.7 kΩ pull-ups; on v1.0 you must supply your own.

Operations

OperationDescription
ReadRead N bytes from a device at the given address
WriteWrite bytes to a device at the given address
Write-ReadWrite then read on the same target (repeated start, no STOP between)
ScanProbe every address on the bus
BatchOne I²C transaction; repeated START on direction change; final STOP only
Set ConfigChange the bus clock frequency at runtime
Get ConfigQuery the current bus configuration

Bus Frequencies

VariantValueStandard name
Standard100 kHzI²C Standard mode
Fast400 kHzI²C Fast mode
FastPlus1 MHzI²C Fast-mode Plus

The firmware defaults to Standard mode.

CLI

$ gallo i2c --help
I2C access methods

Usage: gallo i2c <COMMAND>

Commands:
  scan        Scan I2C bus for existing devices
  read        Read bytes through the I2C bus from device at given address
  write       Write bytes through I2C bus to device at given address
  write-read  Write bytes follwed by read bytes
  set-config  Set I2C bus parameters
  get-config  Query the current I2C bus configuration
  batch       Execute multiple I2C operations in a single USB transfer
  help        Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Scanning

Warning

The RP235x I²C controller doesn’t expose a pure address-probe primitive, so gallo i2c scan does a 1-byte read at each address. Devices that ACK a read are reported as present. A handful of peripherals may end up in an unexpected state after being probed this way — usually a power cycle clears it.

$ gallo i2c scan
╭────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────╮
│    │  0 │  1 │  2 │  3 │  4 │  5 │  6 │  7 │  8 │  9 │  a │  b │  c │  d │  e │  f │
├────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┤
│ 0  │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 1  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 2  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 3  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 4  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ 48 │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 5  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 6  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ 68 │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 7  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │
╰────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────╯

RR marks reserved I²C addresses. Pass -r (--include-reserved) to probe them anyway.

Read / Write / Write-Read

$ gallo i2c read --address 0x48 --count 2
6b 15

$ gallo i2c write --address 0x48 --bytes 0x01 0xe0 0xa0

$ gallo i2c write-read --address 0x48 --bytes 0x00 --count 2
6b 15

Read output supports -f hex (default), -f binary, and -f ascii.

Config

$ gallo i2c set-config --frequency fast
$ gallo i2c get-config
Frequency: Fast (400 kHz)

Batch

One USB round-trip carries one multi-operation I²C transaction. A START and address precede the first operation. Adjacent operations in the same direction run back to back without a STOP or repeated START, so adjacent writes form one gather write. A direction change emits a documented repeated START and re-addressing, and only the final operation is followed by a STOP.

This bus framing requires firmware from schema 0.7 or newer. Older firmware executes each operation as a separate transaction.

$ gallo i2c batch -a 0x48 --op write:0x00 --op read:2
Read data (2 bytes):
  0000: 19 80                                              ..

See Transaction Batching for the full mechanism.

Measured Transfer Limits

I²C uses independent directional ceilings. i2c_read and the read half of i2c_write_read may return at most MAX_RESPONSE_PAYLOAD (1014 bytes). i2c_write and the write half of i2c_write_read may send at most MAX_TRANSFER_SIZE (4096 bytes). Every host surface refuses an over-ceiling argument with BufferTooLong before transmitting.

The response ceiling is derived from the host transport, not fitted to the measurement: 1024 bytes of inbound USB transfer − 7 bytes of response header − 1 postcard Result discriminant − 2 bytes of varint length prefix = 1014.

Before issue #158 added those local guards, the schema-0.7 measurement behind issue #146 found that an I²C read of 1014 bytes after a one-byte write returned exactly the requested length. The 1015-byte probe failed in about 99 ms; larger tested lengths returned the same host-side response decode error, Postcard(DeserializeUnexpectedEnd), with increasing latency, reaching 391 ms at 4096. No truncation was found at checked lengths of 1, 64, 256, 512, 1000, 1013 and 1014 bytes.

No failing write request length was observed through 4096 bytes. Every probe crossed USB intact, was decoded, initiated a bus transaction and returned the expected address NACK. The target address was unpopulated, so no payload byte was clocked: this verifies request framing at 4096, not successful bus-level clocking of a 4096-byte payload. The probe could go no higher because 4096 is MAX_TRANSFER_SIZE.

No hang was found at any tested I²C length, but that does not prove that no I²C hang window exists. The combined write/read frontier remains unmeasured beyond a one-byte write because the available TMP102 rejects longer writes. Issue #158 superseded the earlier inference about independent bounds by deriving and enforcing them explicitly according to direction.

The Zephyr driver and every direct host surface now enforce the same values locally. Zephyr returns -EMSGSIZE; the other surfaces report their BufferTooLong mapping. See troubleshooting.

Rust Library

All PicoDeGallo methods are async. PicoDeGallo::new() is not async.

use pico_de_gallo_lib::{I2cBatchOp, I2cFrequency, PicoDeGallo};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    pg.i2c_set_config(I2cFrequency::Fast).await?;

    // Plain write-read
    let data = pg.i2c_write_read(0x48, &[0x00], 2).await?;
    let raw = u16::from_be_bytes([data[0], data[1]]);
    println!("raw = 0x{raw:04x}");

    // Same bus framing as i2c_write_read above: write, repeated START, read, STOP.
    let ops = [
        I2cBatchOp::Write { data: &[0x00] },
        I2cBatchOp::Read { len: 2 },
    ];
    let _ = pg.i2c_batch(0x48, &ops).await?;
    Ok(())
}

HAL

The HAL exposes the bus as an [embedded_hal::i2c::I2c] / [embedded_hal_async::i2c::I2c] implementor — so any driver written against those traits Just Works:

#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;
use pico_de_gallo_hal::Hal;

fn read_tmp102(hal: &Hal) {
    let mut i2c = hal.i2c();
    let mut buf = [0u8; 2];
    i2c.write_read(0x48, &[0x00], &mut buf).unwrap();
    let raw = u16::from_be_bytes(buf);
    let celsius = (raw >> 4) as f32 * 0.0625;
    println!("Temperature: {celsius:.2} °C");
}
}

I2c::transaction() sends all operations in one USB round-trip and, with schema 0.7 or newer firmware, executes them as one I²C transaction. Adjacent same-direction operations run without an intervening STOP; a direction change emits a documented repeated START. See Transaction Batching.

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void read_tmp102(PicoDeGallo *gallo) {
    uint8_t tx[] = {0x00};
    uint8_t rx[2];
    Status s = gallo_i2c_write_read(gallo, 0x48, tx, 1, rx, 2);
    if (s != Ok) { fprintf(stderr, "write-read failed: %d\n", s); return; }
    uint16_t raw = ((uint16_t)rx[0] << 8) | rx[1];
    printf("raw = 0x%04x\n", raw);
}

I²C frequency is passed as uint8_t: 0 = Standard, 1 = Fast, 2 = FastPlus. See crates/ffi.md.

Python

from pyco_de_gallo import PycoDeGallo, I2cFrequency

pg = PycoDeGallo()
pg.i2c_set_config(I2cFrequency.Fast)

data = pg.i2c_write_read(0x48, [0x00], 2)
raw = (data[0] << 8) | data[1]
print(f"raw = 0x{raw:04x}")

Error Handling

I²C operations return PicoDeGalloError<I2cError> on the Rust side; FFI returns negative Status values:

VariantMeaning
BusUnexpected condition on the I²C bus
NoAcknowledgeTarget did not acknowledge
ArbitrationLossLost arbitration to another master
OverrunData overrun on read
BufferTooLongArgument exceeds its directional payload ceiling
AddressOutOfRangeAddress outside the 7-bit range
OtherUnspecified firmware error
ZeroLengthWriteWrite requested with an empty payload

The full status-code mapping for FFI lives in appendix/status-codes.md. ZeroLengthWrite maps to InvalidArgument (-5).

Zero-Length Writes Are Not Supported

A write with an empty payload — the address-only START + ADDR + STOP probe that some I²C stacks use for bus scanning — is rejected by the firmware with ZeroLengthWrite. This applies to i2c/write and to any Write operation inside an i2c/batch; the batch is refused as a whole during validation, so no earlier operation in it reaches the bus.

Every host surface also refuses it locally, before the request is transmitted, so the call fails immediately instead of spending a USB round-trip to be told no. The Zephyr module is not a host surface — it is an FFI consumer — but it refuses the same shape locally, and is listed here alongside them:

SurfaceRefusal
pico-de-gallo-libPicoDeGalloError::Endpoint(I2cError::ZeroLengthWrite)
pico-de-gallo-halI2cHalError::I2c(ZeroLengthWrite), whose ErrorKind is Other
C FFIStatus::InvalidArgument (-5); for a batch, *out_failed_op names the operation
pyco-de-galloRuntimeError carrying the same message
gallo-mcpAn invalid-argument error naming the offending operation
gallo CLIUnreachable: the byte arguments require at least one value
Zephyr module-ENOTSUP; set CONFIG_I2C_PICO_DE_GALLO_PROBE_WITH_READ to substitute a 1-byte read instead. See zephyr/README.md

Except for the Zephyr opt-in, which substitutes a read rather than refusing, the local refusal returns the identical error the firmware would have returned, so callers need not distinguish the two.

Note that i2c/write-read is not affected: an empty write phase there is legal, because that transfer does not terminate with a STOP. Probing with i2c_write_read(addr, &[], n) works.

The restriction is a hardware limitation, not a firmware policy choice. The RP2040/RP2350 DW_apb_i2c block drives the address phase only as a side effect of pushing data into IC_DATA_CMD, so there is no way to emit an address without at least one payload byte. See rp-rs/rp-hal#678 and embassy-rs/embassy#4474.

To probe for a device, use a 1-byte read instead:

gallo i2c read --address 0x48 --count 1   # NoAcknowledge => absent
gallo i2c scan                            # or scan the whole bus

i2c/scan already probes this way. Note that a read probe is not semantically identical to a write probe: a write-only device may acknowledge its write address while refusing a read address.

SPI

Pico de Gallo drives the RP2350’s SPI0 controller in DMA-backed full-duplex mode.

SignalRP2350 GPIOAvailable onDriven by firmware
SCKGPIO 6v1.0+Yes
MOSI (TX)GPIO 7v1.0+Yes
MISO (RX)GPIO 4v1.0+Yes
SPI_CS netGPIO 5v1.1+No — net label only, not a chip select

There is no dedicated chip-select signal. Despite the SPI_CS silkscreen on header pin 8, GPIO 5 is physically routed on v1.1 but firmware never claims or drives it on either revision. The only firmware-managed chip-select mechanism uses user GPIO indices in 0..num_gpios, where num_gpios is device-reported and currently 4. Those indices map to RP2350 GPIO 8–11 and work with spi/batch, the fallible spi_device(cs_pin) HAL accessor, and equivalent host surfaces. Manually toggling a GPIO around separate SPI operations is also possible, but it does not hold chip-select atomically across the sequence as spi/batch does. See issue #99.

Operations

OperationDescription
ReadClock in N bytes (MISO only)
WriteClock out bytes (MOSI only)
TransferFull-duplex: simultaneous TX and RX
FlushWait for any in-flight transactions to complete
BatchSequence of ops under a single chip-select
Set ConfigChange frequency / CPHA / CPOL at runtime
Get ConfigQuery the current configuration

SPI Mode

SPI mode is the (CPOL, CPHA) tuple. Mode is set via set-config / spi_set_config():

ModeCPOLCPHAIdle clockSample edge
000lowrising
101lowfalling
210highfalling
311highrising

The firmware defaults to mode 0.

CLI

$ gallo spi --help
SPI access methods

Usage: gallo spi <COMMAND>

Commands:
  read        Read bytes through SPI bus
  write       Write bytes through SPI bus
  transfer    Full-duplex SPI transfer (simultaneous write and read)
  write-read  Write bytes followed by read bytes (half-duplex)
  set-config  Set SPI bus parameters
  get-config  Query the current SPI bus configuration
  batch       Execute multiple SPI operations atomically under chip-select
  help        Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Read / Write / Transfer

$ gallo spi read --count 4
00 00 00 00

$ gallo spi write --bytes 0x9f

$ gallo spi transfer --bytes 0x01 0x02 0x03 0x04
00 00 00 00

transfer clocks out the given bytes on MOSI and simultaneously clocks in the same number of bytes on MISO — true full-duplex.

Direction determines the limit. spi_write sends data only and accepts up to MAX_TRANSFER_SIZE (4096 bytes). spi_read returns data and accepts up to MAX_RESPONSE_PAYLOAD (1014 bytes). spi_transfer is full duplex, so every argument byte also has to return; the tighter MAX_RESPONSE_PAYLOAD ceiling therefore limits the whole transfer to 1014 bytes. Every host surface checks these limits before transmitting and reports BufferTooLong for an over-ceiling call.

This asymmetry is intentional and measured: issue #158 observed spi/write succeeding with 1015 bytes at the same boundary where spi/transfer fails.

Config

Mode is selected with a single --mode flag, defaulting to 0:

$ gallo spi set-config -h
Set SPI bus parameters

Usage: gallo spi set-config [OPTIONS] --frequency <FREQUENCY>

Options:
      --frequency <FREQUENCY>  SPI frequency in Hz
      --mode <MODE>            SPI mode 0-3, the conventional (CPOL, CPHA) pairing [default: 0]
  -h, --help                   Print help (see more with '--help')

The default matches the firmware’s power-on configuration, so setting only the clock leaves the mode alone:

$ gallo spi set-config --frequency 1000000
$ gallo spi get-config
SPI frequency: 1000000 Hz
SPI phase:     CaptureOnFirstTransition (CPHA=0)
SPI polarity:  IdleLow (CPOL=0)

Any other mode is one flag away, and out-of-range values are rejected rather than silently masked:

$ gallo spi set-config --frequency 1000000 --mode 3
$ gallo spi set-config --frequency 1000000 --mode 4
error: invalid value '4' for '--mode <MODE>': 4 is not in 0..=3

--first-transition and --idle-low are presence-only boolean flags; neither takes a value, and each defaults to false when omitted.

Batch (Atomic Under CS)

A single transaction with chip-select held low for the duration:

$ gallo spi batch --cs 0 --op write:0x9f --op read:3
Read data (3 bytes):
  0000: ef 40 18                                           .@.

The --cs flag picks which user GPIO drives chip-select. Firmware validates the encoded operations first, then requires the index to be inside 0..DeviceInfo::num_gpios, not monitored for GPIO events, and not explicitly configured as an input. These refusals occur before chip-select is driven: an invalid index does not touch any pin, and the other refusals leave the selected pin’s direction, level, and pull unchanged.

For an accepted transaction, firmware configures the pin as an output, drives it high, asserts it low for the batch, and deasserts it high after execution even when an SPI operation fails. The prior direction and level are not restored, but a pull configured through gpio/set-config is preserved. Firmware predating this contract may instead reconfigure an explicit input pin. See Transaction Batching.

Zephyr chip select: standard cs-gpios

The Zephyr SPI controller driver does not use the batch endpoint. It uses spi/transfer and drives every chip-select edge through the odp,pico-de-gallo-gpio child, using ordinary Zephyr cs-gpios. A child node’s reg therefore has its standard Zephyr meaning: an index into the controller’s cs-gpios array.

#include <zephyr/dt-bindings/gpio/gpio.h>

&pdg0 {
	status = "okay";
	serial-number = "REPLACE_WITH_YOUR_PICO_DE_GALLO_SERIAL";
};

&pdg_gpio0 {
	status = "okay";
};

&pdg_spi0 {
	status = "okay";
	cs-gpios = <&pdg_gpio0 0 GPIO_ACTIVE_LOW>;
};

The SPI controller is a direct child of the pdg0 multi-function-device parent: pdg0 owns the board selection and the USB connection, and the SPI controller borrows that connection rather than opening its own.

cs-gpios is required on every enabled controller; there is no native-CS fallback and a missing property fails devicetree processing. Every entry must target an enabled odp,pico-de-gallo-gpio controller under the same odp,pico-de-gallo parent. A foreign GPIO controller, a disabled sibling, and a Pico de Gallo GPIO controller belonging to a different parent are each rejected at build time with an assertion naming the cs-gpios array index. The cross-parent case is the important one: it is a real, enabled Pico de Gallo GPIO port on a different physical board, so chip select would be driven on one board while data was clocked on another. Because chip select actuates a pin, the parent of an enabled controller must also declare serial-number.

The pin cell is a firmware user GPIO index in the same namespace this page describes — 0–3 on current firmware, not an RP2350 GPIO number and not a header pin number. GPIO_ACTIVE_LOW is typical; GPIO_ACTIVE_HIGH is permitted, because GPIO logical polarity determines the physical edge. The SPI operation flag SPI_CS_ACTIVE_HIGH remains rejected with -ENOTSUP.

What this costs

Chip select is no longer atomic with the data phase. An ordinary successful transceive is four USB round trips:

spi/set-config -> gpio/put(assert) -> spi/transfer -> gpio/put(deassert)

Any of them can fail independently, and host death after the assert can leave chip select asserted; a fresh session can deassert ordinary residue. Issue #178 now bounds every host RPC with a timeout, and issue #157 added a firmware dispatch-progress supervisor as a backstop. If a timed-out request asserted chip select, its device-side fate can still be unknown, so do not assume the timeout deasserted the line.

Historical context still matters: an earlier 1015-byte TX-only request reproduced a device-wide dispatcher wedge, recoverable in that run by USB re-enumeration. Issue #158 could not reproduce that wedge on firmware 62dd64e710fd after #157 and #178 landed: the call returned a clean error in 12 ms and the board stayed responsive. That single-build non-reproduction does not prove the earlier wedge never existed.

Zephyr also collapses a child’s spi-cs-setup-delay-ns and spi-cs-hold-delay-ns into a single DIV_ROUND_UP(MAX(setup_ns, hold_ns), 1000) microsecond value and applies that same delay after the assert and before the deassert. Microsecond waits between millisecond USB round trips cannot provide meaningful nanosecond timing.

Read-only and write-only transfers become full-duplex transfers of max(tx_len, rx_len) bytes, with zero-filled TX or discarded RX respectively.

Declaring a pin in cs-gpios makes the SPI driver the sole driver path for that pin’s mode; it is not an ownership reservation. The application must give SPI exclusive ownership of every declared chip-select pin, because a direct GPIO consumer can otherwise reconfigure or drive it between SPI operations.

Holding chip select, and the fault latch

SPI_HOLD_ON_CS requires SPI_LOCK_ON and returns -ENOTSUP without it: holding chip select while another configuration could select a second slave would leave two peripherals selected at once. A successful hold commits the received data and keeps both the line asserted and the bus locked until spi_release() is called with that same configuration. A thread or process that never releases strands both. A transceive using a different configuration then blocks forever; there is no timeout and no watchdog recovery. HOLD without LOCK is rejected because it would release the controller while CS remained asserted, allowing a second peripheral to be selected and causing MISO contention. On the M5 fixture MOSI and MISO are shorted, so this is not hypothetical.

Received data is committed only after the deassert that ends the transaction is acknowledged, or immediately on a successful deliberate hold. A transfer that succeeds but whose deassert fails returns the deassert errno and does not commit RX: the peripheral may still be selected.

If a forced deassert returns an error the driver cannot tell whether the line went inactive, so the controller latches. Every later transceive then returns -EHOSTDOWN before issuing any configuration, chip-select edge or clocking. Only a spi_release() whose checked deassert succeeds clears it.

Other errors a caller can see include -ENODEV, -EINVAL, -ENOTSUP, -ENOMEM, -EIO / -ECOMM / -EPROTO, -EACCES (a chip-select pin the firmware records as an explicit input), -EBUSY (a chip-select pin under a live firmware GPIO event subscription), and -EMSGSIZE (over PDG_SPI_MAX_BUFFER, 1014 bytes). Issue #158 superseded the old 1013-byte containment and 512-byte documented-safe duplex limit by measuring spi/transfer at the exact boundary: 1014 works and 1015 fails cleanly. PDG_SPI_MAX_BUFFER now lives in pdg_spi_bottom.h, and a _Static_assert ties it to GALLO_MAX_RESPONSE_PAYLOAD; it is not the 4096-byte send-only GALLO_MAX_TRANSFER_SIZE. Stacked drivers collapse these errors into a generic not-ready error — jedec,spi-nor, for instance, reports -ENODEV for any transfer failure — so the controller’s own log line is the only authoritative diagnosis.

gallo spi batch and the host spi_batch APIs described above are unchanged and remain fully supported; only the Zephyr module stopped using them. zephyr/README.md in the repository remains the detailed module guide.

Note

The Zephyr driver and every direct host surface enforce the directional limits locally. Zephyr returns -EMSGSIZE; Rust, C, Python, CLI, and MCP use their BufferTooLong mapping. spi_write accepts 4096 bytes, but any SPI operation that returns bytes is limited to 1014; see troubleshooting.

Rust Library

use pico_de_gallo_lib::{PicoDeGallo, SpiBatchOp, SpiPhase, SpiPolarity};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    // Mode 0: sample on the first transition, clock idles low.
    pg.spi_set_config(
        1_000_000,
        SpiPhase::CaptureOnFirstTransition,
        SpiPolarity::IdleLow,
    )
    .await?;

    // Read JEDEC ID under CS on GPIO 0
    let ops = [
        SpiBatchOp::Write { data: &[0x9F] },
        SpiBatchOp::Read { len: 3 },
    ];
    let result = pg.spi_batch(0, &ops).await?;
    println!(
        "JEDEC: mfr=0x{:02x} type=0x{:02x} cap=0x{:02x}",
        result[0], result[1], result[2]
    );
    Ok(())
}

HAL

The HAL provides two flavours of SPI access:

  • hal.spi() — a raw embedded_hal::spi::SpiBus / embedded_hal_async::spi::SpiBus implementor. You manage chip-select yourself.
  • hal.spi_device(cs_pin) — an SpiDevice that automatically drives the given GPIO as chip-select around every transaction.

Host chip-select preflight

Every host surface checks the chip-select index against the GPIO count the connected device reports in device/info, before the pin is driven and before any spi/batch request is transmitted. The bound is therefore runtime-authoritative: it comes from the board, not from a compile-time constant.

The count is resolved lazily. The first call that needs it performs one implicit validated device/info round-trip — bounded at 300 seconds — and caches the result for the lifetime of the connection; handles cloned from the same connection share that cache. A failed lookup is not cached, so the next call retries.

The failure modes stay disjoint, which is the point:

  • the index is at or beyond the reported count → invalid chip-select;
  • the device reports zero GPIOs → its own distinct error, for every index;
  • the count could not be established (transport failure, 300-second timeout, legacy firmware, schema mismatch) → a communications / compatibility error, never an invalid chip-select. Misreporting a metadata failure as a bad argument would send you hunting for a bug in your own code.

A refused chip-select drives no pin and transmits nothing, so the pin keeps whatever direction you configured.

#![allow(unused)]
fn main() {
use embedded_hal::spi::{Operation, SpiDevice};
use pico_de_gallo_hal::Hal;

fn read_jedec(hal: &Hal) -> [u8; 3] {
    // `spi_device` returns a Result: the chip-select is validated against
    // the device-reported GPIO count before the pin is driven.
    let mut spi = hal.spi_device(0).expect("CS 0 is valid on this board");
    let mut id = [0u8; 3];

    // One transaction; CS asserted for the whole thing; batched into
    // one USB round-trip transparently.
    spi.transaction(&mut [
        Operation::Write(&[0x9F]),
        Operation::Read(&mut id),
    ])
    .unwrap();
    id
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void read_jedec(PicoDeGallo *gallo) {
    /* mode 0, 1 MHz */
    gallo_spi_set_config(gallo, 1000000, /*phase=*/false, /*polarity=*/false);

    uint8_t cmd[] = {0x9F};
    gallo_spi_write(gallo, cmd, 1);

    uint8_t id[3];
    gallo_spi_read(gallo, id, sizeof(id));
    printf("JEDEC: %02x %02x %02x\n", id[0], id[1], id[2]);
}

For atomic chip-select transactions, batch operations are available — see the gallo_spi_batch_* family in the generated pico_de_gallo.h.

Python

from pyco_de_gallo import PycoDeGallo, SpiPhase, SpiPolarity

pg = PycoDeGallo()
# Mode 0: sample on the first transition, clock idles low.
pg.spi_set_config(
    1_000_000, SpiPhase.CaptureOnFirstTransition, SpiPolarity.IdleLow
)

pg.spi_write(bytes([0x9F]))
id_bytes = pg.spi_read(3)
print("JEDEC:", id_bytes.hex())

Error Handling

VariantMeaning
BufferTooLongRequest exceeds a local operation limit or framed transport budget; usable payload is shape-dependent
OtherCatch-all for firmware-reported SPI failure
InvalidCsPinChip-select index outside 0..DeviceInfo::num_gpios
CsPinUnavailableChip-select pin is explicitly configured as an input
CsPinMonitoredChip-select pin is monitored for GPIO events

See appendix/status-codes.md for the FFI mapping.

UART

Hardware revision note: UART requires hw-rev2 firmware. On v1 hardware, UART endpoints return UartError::Unsupported.

Pico de Gallo provides UART support through the RP2350’s hardware UART0 peripheral. The TX pin is on GPIO 0 and RX is on GPIO 1. The UART is buffered and interrupt-driven, so reads and writes do not block the firmware’s main loop.

Operations

OperationDescription
ReadReads up to N bytes from the receive buffer with an optional timeout
WriteWrites raw bytes to the transmit buffer
FlushFlushes the transmit buffer, blocking until all bytes are sent
Set ConfigReplaces the baud rate and framing configuration
Get ConfigReturns the current UART configuration

Framing Configuration

The firmware initializes UART0 to 115200 8N1: 115200 bits per second, eight data bits, no parity, and one stop bit. At runtime, set-config can change the baud rate and every supported framing parameter.

The RP2350 UART0 capability envelope is:

ParameterValuesResult
Data bits5, 6, 7, 8Supported
Data bits9Not available
ParityNone, odd, evenSupported
ParityMark, spaceSupported as stick parity
Stop bits1, 2Supported
Stop bits0.5, 1.5Not available
Flow controlNoneSupported
Flow controlRTS/CTSNot available
Flow controlDTR/DSRNot available
Flow controlRS-485Not available

The mark parity setting always means a parity bit of 1; the space parity setting always means 0. These modes are not exposed by embassy’s Parity enum, so the firmware configures the PL011 framing register directly.

Warning

Reconfiguration is not atomic at the UART pins. The device applies the baud divisor first and the framing second, and drains neither direction. There is a window in which the new divisor is active with the old framing. Quiesce transmit and receive traffic while changing the configuration.

Warning

set-config replaces the complete configuration. In the CLI, omitting a framing flag selects its 8N1 power-on value and overwrites the previous setting. A baud-only change must therefore repeat all framing flags.

For example, configure 9600 baud with seven data bits, even parity, and two stop bits:

gallo uart set-config --baud-rate 9600 \
    --data-bits 7 --parity even --stop-bits 2
gallo uart get-config

get-config reports the last successfully requested configuration. This is a software shadow, not a UART register read-back, so the reported baud rate does not reflect divisor rounding.

Loopback Example

The simplest way to verify UART operation is a loopback test: connect GPIO 0 (TX) directly to GPIO 1 (RX) with a jumper wire. Everything you write will be received back.

CLI

# 1. Check the current configuration
gallo uart get-config

# 2. Set the complete configuration to 115200 8N1 (default)
gallo uart set-config --baud-rate 115200 \
    --data-bits 8 --parity none --stop-bits 1

# 3. Write "Hello" (ASCII bytes)
gallo uart write --bytes 0x48 0x65 0x6C 0x6C 0x6F

# 4. Read back 5 bytes with a 100ms timeout
gallo uart read --count 5 --timeout 100

# 5. Flush the transmit buffer
gallo uart flush

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{
    PicoDeGallo, UartDataBits, UartParity, UartStopBits,
};

async fn uart_loopback(gallo: &PicoDeGallo) {
    // Configure 115200 8N1
    gallo
        .uart_set_config(
            115_200,
            UartDataBits::Eight,
            UartParity::None,
            UartStopBits::One,
        )
        .await
        .unwrap();

    // Verify configuration
    let config = gallo.uart_get_config().await.unwrap();
    println!("Baud rate: {}", config.baud_rate);

    // Write "Hello"
    gallo.uart_write(&[0x48, 0x65, 0x6C, 0x6C, 0x6F]).await.unwrap();

    // Flush to ensure all bytes are transmitted
    gallo.uart_flush().await.unwrap();

    // Read back with 100ms timeout
    let data = gallo.uart_read(5, 100).await.unwrap();
    assert_eq!(&data, &[0x48, 0x65, 0x6C, 0x6C, 0x6F]);
    println!("Received: {:?}", String::from_utf8_lossy(&data));
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>
#include <string.h>

void uart_loopback(const PicoDeGallo *gallo) {
    /* Configure 115200 8N1 */
    Status rc = gallo_uart_set_config(
        gallo,
        115200,
        GalloUartDataBits_Eight,
        GalloUartParity_None,
        GalloUartStopBits_One);
    if (rc != Ok) {
        fprintf(stderr, "set-config failed: %d\n", rc);
        return;
    }

    /* Read back current config */
    uint32_t baud_rate;
    uint8_t data_bits;
    uint8_t parity;
    uint8_t stop_bits;
    rc = gallo_uart_get_config(
        gallo, &baud_rate, &data_bits, &parity, &stop_bits);
    if (rc != Ok) {
        fprintf(stderr, "get-config failed: %d\n", rc);
        return;
    }
    printf("Baud rate: %u\n", baud_rate);

    /* Write "Hello" */
    uint8_t tx[] = {0x48, 0x65, 0x6C, 0x6C, 0x6F};
    rc = gallo_uart_write(gallo, tx, sizeof(tx));
    if (rc != Ok) {
        fprintf(stderr, "write failed: %d\n", rc);
        return;
    }

    /* Flush */
    gallo_uart_flush(gallo);

    /* Read back */
    uint8_t rx[5];
    uint16_t out_read;
    rc = gallo_uart_read(gallo, rx, sizeof(rx), 100, &out_read);
    if (rc != Ok) {
        fprintf(stderr, "read failed: %d\n", rc);
        return;
    }

    printf("Received %u bytes: %.*s\n", out_read, out_read, rx);
}

HAL

The HAL layer implements the standard embedded_io and embedded_io_async traits, so the UART can be used with any driver that accepts generic readers or writers.

The embedded-io-06 Cargo feature remains enabled by default, while embedded-io-07 can be enabled additively. See Feature Flags for the details. The snippets below are identical either way.

Blocking — embedded_io::Read + embedded_io::Write:

#![allow(unused)]
fn main() {
use embedded_io::{Read, Write};
use pico_de_gallo_hal::Hal;

fn uart_loopback_blocking(hal: &Hal) {
    let mut uart = hal.uart();

    // Write "Hello"
    uart.write_all(&[0x48, 0x65, 0x6C, 0x6C, 0x6F]).unwrap();
    uart.flush().unwrap();

    // Read back
    let mut buf = [0u8; 5];
    uart.read_exact(&mut buf).unwrap();
    assert_eq!(&buf, b"Hello");
}
}

Async — embedded_io_async::Read + embedded_io_async::Write:

#![allow(unused)]
fn main() {
use embedded_io_async::{Read, Write};
use pico_de_gallo_hal::Hal;

async fn uart_loopback_async(hal: &Hal) {
    let mut uart = hal.uart();

    uart.write_all(&[0x48, 0x65, 0x6C, 0x6C, 0x6F]).await.unwrap();
    uart.flush().await.unwrap();

    let mut buf = [0u8; 5];
    uart.read_exact(&mut buf).await.unwrap();
    assert_eq!(&buf, b"Hello");
}
}

Connecting an External Device

To communicate with an external UART device (e.g., a GPS module or microcontroller), connect:

Pico de Gallo          External Device
──────────────         ───────────────
GPIO 0 (TX) ────────── RX
GPIO 1 (RX) ────────── TX
GND ────────────────── GND

Note

Cross the TX/RX lines: the transmit pin of one device connects to the receive pin of the other.

Non-blocking Read

A timeout of 0 performs a single non-blocking poll and returns whatever bytes are already in the receive buffer (possibly none). This is a deliberate exception to GPIO wait semantics. Non-zero values above the firmware’s 30-minute ceiling are clamped to that ceiling:

# Non-blocking: return whatever is buffered right now
gallo uart read --count 64 --timeout 0
#![allow(unused)]
fn main() {
use pico_de_gallo_lib::PicoDeGallo;

async fn drain_buffer(gallo: &PicoDeGallo) -> Vec<u8> {
    // timeout_ms = 0 → one non-blocking poll
    gallo.uart_read(64, 0).await.unwrap()
}
}

Error Handling

UART direction determines the payload ceiling. uart_read may return at most MAX_RESPONSE_PAYLOAD (1014 bytes), while uart_write may send at most MAX_TRANSFER_SIZE (4096 bytes). Every host surface checks this before transmitting and reports BufferTooLong for an over-ceiling call.

UART operations return PicoDeGalloError<UartError> on failure. The UartError variants cover both protocol-level and configuration errors:

VariantDescription
BufferTooLongRead exceeds the response ceiling or write exceeds the transfer ceiling
OverrunReceive buffer overflowed before host read the data
BreakBreak condition detected on the line
ParityParity check failed
FramingInvalid stop bit detected
InvalidBaudRateRequested baud rate is out of range or unsupported
OtherCatch-all for unexpected firmware errors
UnsupportedUART is not available on this hardware revision

API Reference

Lib Methods

All methods are async and available on PicoDeGallo:

MethodSignature
uart_readuart_read(count: u16, timeout_ms: u32) -> Result<Vec<u8>, PicoDeGalloError<UartError>>
uart_writeuart_write(contents: &[u8]) -> Result<(), PicoDeGalloError<UartError>>
uart_flushuart_flush() -> Result<(), PicoDeGalloError<UartError>>
uart_set_configuart_set_config(baud_rate: u32, data_bits: UartDataBits, parity: UartParity, stop_bits: UartStopBits) -> Result<(), PicoDeGalloError<UartError>>
uart_get_configuart_get_config() -> Result<UartConfigurationInfo, PicoDeGalloError<UartError>>

Note

PicoDeGallo::new() is not async. Only the peripheral methods listed above are async.

FFI Functions

All FFI functions return a Status code:

Status gallo_uart_read(const PicoDeGallo *gallo,
                       uint8_t *buf, uint16_t buf_len,
                       uint32_t timeout_ms, uint16_t *out_read);

Status gallo_uart_write(const PicoDeGallo *gallo,
                        const uint8_t *buf, uint16_t len);

Status gallo_uart_flush(const PicoDeGallo *gallo);

Status gallo_uart_set_config(const PicoDeGallo *gallo,
                             uint32_t baud_rate,
                             uint8_t data_bits,
                             uint8_t parity,
                             uint8_t stop_bits);

Status gallo_uart_get_config(const PicoDeGallo *gallo,
                             uint32_t *out_baud_rate,
                             uint8_t *out_data_bits,
                             uint8_t *out_parity,
                             uint8_t *out_stop_bits);

CLI Commands

gallo uart read       --count <N> --timeout <MS>
gallo uart write      --bytes <BYTE>...
gallo uart flush
gallo uart set-config --baud-rate <BAUD_RATE> [--data-bits <5|6|7|8>] \
                      [--parity <none|odd|even|mark|space>] \
                      [--stop-bits <1|2>]
gallo uart get-config
gallo uart help

Pin Mapping

FunctionGPIORP2350 Peripheral
TX0UART0 TX
RX1UART0 RX

GPIO

Pico de Gallo exposes 4 general-purpose I/O pins (GPIO 0–3) mapped to RP2350 GPIO 8–11.

Pin Mapping

Gallo PinRP2350 GPIO
08
19
210
311

Operations

OperationDescription
GetRead the current pin state (High or Low)
PutDrive a pin High or Low
Set ConfigConfigure pin direction (input/output) and pull resistor (none/up/down)
MonitorSubscribe to edge events on a pin (rising, falling, or any)

Pin Configuration

Before using a GPIO pin, configure its direction and pull resistor. After power-on a pin is in the firmware’s LegacyAuto mode: it has no explicit direction, and the firmware lazily selects one per operation — a read switches the pad to input, a write drives it. An explicit set-config leaves that mode and pins the direction, after which a write to an explicit input, or a read of an explicit output, is rejected.

Warning. Do not use the RP2350 internal pull-down as proof that an input is low. It can hold a node that was previously driven low, but may not discharge a node that is already high; an unpulled floating input can also drift high within seconds. For a deterministic low state, drive the node low first and verify it before releasing it to pull-down, or use an external pull-down. Configuring pull-down alone does not guarantee that gpio/get returns Low.

CLI

# Configure pin 0 as input with pull-up
gallo gpio set-config --pin 0 --direction input --pull up

# Configure pin 2 as output with no pull
gallo gpio set-config --pin 2 --direction output --pull none

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, GpioDirection, GpioPull};

fn configure_pins(gallo: &PicoDeGallo) {
    // Configure pin 0 as input with pull-up
    smol::block_on(async {
        gallo
            .gpio_set_config(0, GpioDirection::Input, GpioPull::Up)
            .await
            .unwrap();

        // Configure pin 2 as output with no pull
        gallo
            .gpio_set_config(2, GpioDirection::Output, GpioPull::None)
            .await
            .unwrap();
    });
}
}

C (FFI)

#include "pico_de_gallo.h"

void configure_pins(PicoDeGallo *gallo) {
    /* Configure pin 0 as input with pull-up */
    GalloStatus rc = gallo_gpio_set_config(
        gallo, 0, GpioDirection_Input, GpioPull_Up
    );
    if (rc != GalloStatus_Ok) {
        fprintf(stderr, "set-config failed: %d\n", rc);
    }

    /* Configure pin 2 as output with no pull */
    gallo_gpio_set_config(gallo, 2, GpioDirection_Output, GpioPull_None);
}

Reading and Writing Pins

CLI

# Read the state of pin 0
gallo gpio get --pin 0
# Output: Pin 0: High

# Drive pin 2 high
gallo gpio put --pin 2 --level high

# Drive pin 2 low
gallo gpio put --pin 2 --level low

--level is required and takes high or low; there is no short option for it, because -h is --help.

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, GpioState};

async fn read_write(gallo: &PicoDeGallo) {
    // Read pin 0
    let state = gallo.gpio_get(0).await.unwrap();
    println!("Pin 0 is {:?}", state);

    // Drive pin 2 high
    gallo.gpio_put(2, GpioState::High).await.unwrap();

    // Drive pin 2 low
    gallo.gpio_put(2, GpioState::Low).await.unwrap();
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void read_write(PicoDeGallo *gallo) {
    /* Read pin 0 */
    bool high;
    GalloStatus rc = gallo_gpio_get(gallo, 0, &high);
    if (rc == GalloStatus_Ok) {
        printf("Pin 0: %s\n", high ? "High" : "Low");
    }

    /* Drive pin 2 high */
    gallo_gpio_put(gallo, 2, true);

    /* Drive pin 2 low */
    gallo_gpio_put(gallo, 2, false);
}

Waiting for Pin State Changes

The library provides async methods that block until a pin reaches the requested state or edge transition. These are useful for waiting on external signals without polling.

Timeout semantics

All five gpio/wait-* endpoints carry a firmware-side timeout. A timeout_ms value of 0 selects the firmware’s 30-minute ceiling; it does not wait forever. Values above 30 minutes are clamped to that same ceiling. If the requested state or edge does not arrive before the effective timeout, the endpoint returns GpioError::Timeout.

The Rust methods without a _with_timeout suffix and the C functions without a _with_timeout_ms suffix send 0, so they also expire at the 30-minute ceiling. Use the explicit timeout variants when a shorter bound is appropriate.

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::PicoDeGallo;

async fn wait_for_button(gallo: &PicoDeGallo) {
    // Wait until pin 1 goes high
    gallo.gpio_wait_for_high(1).await.unwrap();
    println!("Pin 1 is now high");

    // Wait until pin 1 goes low
    gallo.gpio_wait_for_low(1).await.unwrap();
    println!("Pin 1 is now low");

    // Wait for a rising edge on pin 1
    gallo.gpio_wait_for_rising_edge(1).await.unwrap();
    println!("Rising edge detected on pin 1");

    // Wait for a falling edge on pin 1
    gallo.gpio_wait_for_falling_edge(1).await.unwrap();
    println!("Falling edge detected on pin 1");

    // Wait for any edge on pin 1
    gallo.gpio_wait_for_any_edge(1).await.unwrap();
    println!("Edge detected on pin 1");
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void wait_for_button(PicoDeGallo *gallo) {
    /* These calls wait up to the firmware's 30-minute ceiling. */
    gallo_gpio_wait_for_high(gallo, 1);
    printf("Pin 1 is now high\n");

    gallo_gpio_wait_for_low(gallo, 1);
    printf("Pin 1 is now low\n");

    gallo_gpio_wait_for_rising_edge(gallo, 1);
    printf("Rising edge detected on pin 1\n");

    gallo_gpio_wait_for_falling_edge(gallo, 1);
    printf("Falling edge detected on pin 1\n");

    gallo_gpio_wait_for_any_edge(gallo, 1);
    printf("Edge detected on pin 1\n");
}

Edge Event Monitoring

For continuous monitoring, subscribe to GPIO edge events on a pin. The firmware streams GpioEvent structs to the host whenever the subscribed edge is detected. Each event carries:

pub struct GpioEvent {
    pub pin: u8,
    pub edge: GpioEdge,
    pub state: GpioState,
    pub timestamp_us: u64,
}
  • pin — the Gallo pin number (0–3)
  • edge — the edge that triggered the event (Rising or Falling)
  • state — the pin state after the edge
  • timestamp_us — firmware timestamp in microseconds

CLI

The monitor subcommand subscribes to edge events and prints them until you press Ctrl+C:

# Monitor rising edges on pin 0
gallo gpio monitor --pin 0 --edge rising

# Monitor any edge on pin 1
gallo gpio monitor --pin 1 --edge any

Example output:

[  12345 µs] Pin 0: Rising  → High
[  12890 µs] Pin 0: Rising  → High
[  45012 µs] Pin 0: Rising  → High
^C
Unsubscribed from pin 0.

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, GpioEdge};

async fn monitor_pin(gallo: &PicoDeGallo) {
    // Subscribe to rising edges on pin 0
    gallo.gpio_subscribe(0, GpioEdge::Rising).await.unwrap();

    // Open a subscription to receive GpioEvent values (buffer depth 16)
    let mut sub = gallo.subscribe_gpio_events(16).await.unwrap();

    // Process events
    for _ in 0..100 {
        let event = sub.recv().await.unwrap();
        println!(
            "[{:>8} µs] Pin {}: {:?} → {:?}",
            event.timestamp_us, event.pin, event.edge, event.state
        );
    }

    // Unsubscribe when done
    gallo.gpio_unsubscribe(0).await.unwrap();
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void monitor_pin(PicoDeGallo *gallo) {
    /* Subscribe to rising edges on pin 0 */
    gallo_gpio_subscribe(gallo, 0, GpioEdge_Rising);

    /* ... receive events via topic subscription API ... */

    /* Unsubscribe when done */
    gallo_gpio_unsubscribe(gallo, 0);
}

HAL Usage

The pico-de-gallo-hal crate implements the standard embedded-hal traits over the GPIO pins, providing a familiar interface for portable device drivers.

Blocking Traits

The HAL implements these blocking traits from embedded-hal:

  • OutputPin — set_high() / set_low()
  • InputPin — is_high() / is_low()
  • StatefulOutputPin — is_set_high() / is_set_low()
#![allow(unused)]
fn main() {
use embedded_hal::digital::{InputPin, OutputPin, StatefulOutputPin};
use pico_de_gallo_hal::Hal;

fn blink_and_read(hal: &Hal) {
    let mut led = hal.output_pin(2);
    let button = hal.input_pin(0);

    // Drive pin 2 high
    led.set_high().unwrap();

    // Read pin 0
    if button.is_high().unwrap() {
        println!("Button pressed");
    }

    // Check what we're currently driving
    if led.is_set_high().unwrap() {
        println!("LED is on");
    }

    led.set_low().unwrap();
}
}

Async Trait

The HAL implements the embedded-hal-async Wait trait for non-blocking edge/level detection:

#![allow(unused)]
fn main() {
use embedded_hal_async::digital::Wait;
use pico_de_gallo_hal::Hal;

async fn wait_for_signal(hal: &Hal) {
    let mut pin = hal.input_pin(1);

    pin.wait_for_high().await.unwrap();
    println!("Pin went high");

    pin.wait_for_rising_edge().await.unwrap();
    println!("Rising edge detected");
}
}

Complete Example: Button-Controlled LED

This example configures pin 0 as an input (button with pull-up) and pin 2 as an output (LED). It toggles the LED on each button press.

CLI

# Configure pins
gallo gpio set-config --pin 0 --direction input --pull up
gallo gpio set-config --pin 2 --direction output --pull none

# Read button, toggle LED manually
STATE=$(gallo gpio get --pin 0)
gallo gpio put --pin 2 --level high

# Or monitor button presses
gallo gpio monitor --pin 0 --edge falling

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{
    PicoDeGallo, GpioDirection, GpioPull, GpioState, GpioEdge,
    PicoDeGalloError, GpioError,
};

async fn button_led() -> Result<(), PicoDeGalloError<GpioError>> {
    let gallo = PicoDeGallo::new();

    // Configure pin 0 as input with pull-up (button)
    gallo.gpio_set_config(0, GpioDirection::Input, GpioPull::Up).await?;

    // Configure pin 2 as output (LED)
    gallo.gpio_set_config(2, GpioDirection::Output, GpioPull::None).await?;

    let mut led_on = false;

    loop {
        // Wait for button press (falling edge because of pull-up)
        gallo.gpio_wait_for_falling_edge(0).await?;

        led_on = !led_on;
        let state = if led_on { GpioState::High } else { GpioState::Low };
        gallo.gpio_put(2, state).await?;
    }
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdbool.h>

int button_led(void) {
    PicoDeGallo *gallo = gallo_new();
    if (!gallo) return -1;

    /* Configure pin 0 as input with pull-up (button) */
    gallo_gpio_set_config(gallo, 0, GpioDirection_Input, GpioPull_Up);

    /* Configure pin 2 as output (LED) */
    gallo_gpio_set_config(gallo, 2, GpioDirection_Output, GpioPull_None);

    bool led_on = false;

    for (;;) {
        /* Wait for button press (falling edge) */
        gallo_gpio_wait_for_falling_edge(gallo, 0);

        led_on = !led_on;
        gallo_gpio_put(gallo, 2, led_on);
    }

    return 0;
}

Error Handling

All GPIO operations return errors through the standard PicoDeGalloError wrapper. GPIO-specific errors are represented as PicoDeGalloError<GpioError>:

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, PicoDeGalloError, GpioError, GpioState};

async fn safe_read(gallo: &PicoDeGallo) {
    match gallo.gpio_get(0).await {
        Ok(state) => println!("Pin 0: {:?}", state),
        Err(PicoDeGalloError::Rpc(e)) => {
            eprintln!("RPC error: {e:?}");
        }
        Err(PicoDeGalloError::Endpoint(gpio_err)) => {
            eprintln!("GPIO error: {gpio_err:?}");
        }
        Err(e) => {
            eprintln!("Other error: {e:?}");
        }
    }
}
}

API Reference

Lib Methods

All methods are async and available on PicoDeGallo:

MethodReturnsDescription
gpio_get(pin: u8)Result<GpioState, ...>Read pin state
gpio_put(pin: u8, state: GpioState)Result<(), ...>Set pin state
gpio_set_config(pin, direction, pull)Result<(), ...>Configure direction and pull
gpio_wait_for_high(pin: u8)Result<(), ...>Wait until pin is high
gpio_wait_for_low(pin: u8)Result<(), ...>Wait until pin is low
gpio_wait_for_rising_edge(pin: u8)Result<(), ...>Wait for low→high transition
gpio_wait_for_falling_edge(pin: u8)Result<(), ...>Wait for high→low transition
gpio_wait_for_any_edge(pin: u8)Result<(), ...>Wait for any transition
gpio_subscribe(pin: u8, edge: GpioEdge)Result<(), ...>Subscribe to edge events on a pin
gpio_unsubscribe(pin: u8)Result<(), ...>Unsubscribe from edge events
subscribe_gpio_events(depth)Result<Subscription<GpioEvent>, ...>Open a subscription to receive GPIO events

FFI Functions

All functions return GalloStatus:

FunctionDescription
gallo_gpio_get(gallo, pin, out_high)Read pin state into *out_high
gallo_gpio_put(gallo, pin, high)Set pin state
gallo_gpio_set_config(gallo, pin, direction, pull)Configure direction and pull
gallo_gpio_wait_for_high(gallo, pin)Block until pin is high
gallo_gpio_wait_for_low(gallo, pin)Block until pin is low
gallo_gpio_wait_for_rising_edge(gallo, pin)Block until rising edge
gallo_gpio_wait_for_falling_edge(gallo, pin)Block until falling edge
gallo_gpio_wait_for_any_edge(gallo, pin)Block until any edge
gallo_gpio_subscribe(gallo, pin, edge)Subscribe to edge events
gallo_gpio_unsubscribe(gallo, pin)Unsubscribe from edge events

CLI Commands

CommandDescription
gallo gpio get --pin NRead pin state
gallo gpio put --pin N --level highDrive pin high
gallo gpio put --pin N --level lowDrive pin low
gallo gpio set-config --pin N --direction DIR --pull PULLConfigure pin
gallo gpio monitor --pin N --edge EDGEStream edge events until Ctrl+C

Zephyr

The pico-de-gallo/zephyr module ships an odp,pico-de-gallo-gpio controller so a Zephyr application running on native_sim can drive these pins through the standard Zephyr GPIO API. Full build instructions live in zephyr/README.md.

Topology. The controller is a direct child of the odp,pico-de-gallo MFD parent, which owns the USB connection. Enable both:

&pdg0 {
	status = "okay";
	serial-number = "E6614C311B8C9E37";
};

&pdg_gpio0 {
	status = "okay";
};

serial-number on the parent is mandatory for an enabled GPIO child and is enforced at build time. GPIO actuates physical pins, and a connection opened without a selector cannot report which attached board it chose. Presence is not uniqueness: two parents naming the same serial still alias to one board.

Pin indices are the firmware GPIO indices 0–3 above, not RP2350 numbers. ngpios must equal the firmware-reported GPIO count or initialization fails with -EINVAL.

Supported flags: GPIO_INPUT, GPIO_OUTPUT, GPIO_PULL_UP, GPIO_PULL_DOWN, GPIO_OUTPUT_INIT_LOW, GPIO_OUTPUT_INIT_HIGH and GPIO_ACTIVE_LOW.

Rejected: GPIO_DISCONNECTED and GPIO_INPUT | GPIO_OUTPUT together (-ENOTSUP); single-ended, open-source and open-drain (-ENOTSUP); interrupt-mode flags including GPIO_INT_WAKEUP (-ENOTSUP); any flag bit outside the supported set (-ENOTSUP); both pulls, both init levels, or an init level without GPIO_OUTPUT (-EINVAL).

Blocking. Every operation that reaches hardware is a USB round trip. Calls from interrupt context return -EWOULDBLOCK; transport failure is -EIO. Configuration and writes may return -EBUSY when a live firmware event subscription owns the pin. Write paths may return -EACCES when firmware records the pin as an explicit input. During a read, gpio_pin_get() normalizes subscription -EBUSY to -EIO and treats -EACCES from an explicit output as a zero bit.

Reads are destructive on unconfigured pins. gpio_port_get_raw() scans every pin. Firmware gpio/get switches a LegacyAuto pad to input, so gpio_pin_get() can reconfigure unrelated unconfigured pins as a side effect. Configure all pins explicitly and do not treat reads as state-neutral queries.

Non-atomic. Multi-pin writes are ascending per-pin round trips with no rollback: on failure the acknowledged prefix definitely changed, the failed pin is indeterminate, and later pins were never issued. Output initialization is two round trips, so the previous level can briefly appear before the requested one.

No toggle, no interrupts. gpio_pin_toggle() returns -ENOTSUP, because an explicit output cannot be read back and the driver deliberately caches no pin state. Interrupt configuration, callback management and the pending-interrupt query return -ENOSYS. Generic toggle consumers — blinky, the GPIO shell, the TPS382x watchdog and the LS0xx display — therefore do not work with this controller.

Serving as SPI chip select

This controller is also how the odp,pico-de-gallo-spi controller drives chip select. Each cs-gpios entry on an enabled SPI controller must name an enabled odp,pico-de-gallo-gpio controller under the same odp,pico-de-gallo parent; a foreign controller, a disabled sibling, or a Pico de Gallo GPIO controller belonging to a different parent is rejected at build time with an assertion naming the cs-gpios array index. Enabling pdg_gpio0 is therefore now a prerequisite for using SPI, not an optional extra. See SPI.

At initialization the SPI controller configures every declared chip-select pin as an explicit output, inactive, in ascending array order. That is two USB round trips per pin (set-config, then put), and there is no rollback: if a pin fails, earlier entries are acknowledged inactive, that entry’s state is indeterminate, and later entries were never issued. A pin under a live firmware GPIO event subscription fails with -EBUSY; reset it explicitly with gallo_system_reset_subscriptions() after a strict open, then reinitialize, or power-cycle the board.

A declared chip-select pin must be owned exclusively by SPI. The GPIO child being the sole driver path for the pin’s mode is not an ownership reservation: a direct GPIO consumer in the same application can reconfigure or drive that pin between SPI operations, and nothing detects it.

Limitations

  • 4 pins only — GPIO 0–3 (RP2350 GPIO 8–11).
  • Shared with Logic Capture — pins used by an active capture session cannot be used for GPIO operations. They are returned automatically when capture stops.
  • No analog — all pins are digital only.
  • Edge event timestamps come from the firmware’s microsecond timer, not the host clock. Events are timestamped when the edge is detected on the RP2350.

PWM

Pico de Gallo provides 4 PWM output channels through the RP2350’s hardware PWM slices. Each channel maps to a specific GPIO pin and slice/channel combination.

Pin Mapping

PWM ChannelGPIOSliceSlice Channel
0126A
1136B
2147A
3157B

The total number of available channels is defined by the constant NUM_PWM_CHANNELS = 4. Channel indices are 0–3 in all APIs.

Operations

OperationDescription
Set DutySets the duty cycle for a channel (0–65535)
Get DutyReturns current and maximum duty cycle
EnableEnables PWM output on a channel
DisableDisables PWM output on a channel
Set ConfigConfigures frequency and phase-correct mode
Get ConfigReturns current configuration

LED Brightness Example

A common use case is driving an LED at variable brightness. Connect an LED (with appropriate current-limiting resistor) to one of the PWM pins and control its brightness through the duty cycle.

CLI

# 1. Configure channel 0 for 1 kHz, normal (not phase-correct) mode
gallo pwm set-config --channel 0 --frequency 1000

# 2. Enable PWM output on channel 0
gallo pwm enable --channel 0

# 3. Set duty cycle to 50% (32768 out of 65535)
gallo pwm set-duty --channel 0 --duty 32768

# 4. Read back the current duty cycle
gallo pwm get-duty --channel 0

# 5. Dim the LED to ~25%
gallo pwm set-duty --channel 0 --duty 16384

# 6. Read back the current configuration
gallo pwm get-config --channel 0

# 7. Switch to phase-correct mode at 500 Hz
gallo pwm set-config --channel 0 --frequency 500 --phase-correct

# 8. Disable the channel when done
gallo pwm disable --channel 0

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::PicoDeGallo;

async fn led_brightness(gallo: &PicoDeGallo) {
    // Configure channel 0: 1 kHz, no phase-correct
    gallo.pwm_set_config(0, 1_000, false).await.unwrap();

    // Enable PWM output
    gallo.pwm_enable(0).await.unwrap();

    // Set 50% duty cycle
    gallo.pwm_set_duty_cycle(0, 32_768).await.unwrap();

    // Read back duty info
    let duty_info = gallo.pwm_get_duty_cycle(0).await.unwrap();
    println!(
        "Duty: {}/{} ({:.1}%)",
        duty_info.current_duty,
        duty_info.max_duty,
        duty_info.current_duty as f32 / duty_info.max_duty as f32 * 100.0
    );

    // Read back configuration
    let config = gallo.pwm_get_config(0).await.unwrap();
    println!(
        "Frequency: {} Hz, Phase-correct: {}, Enabled: {}",
        config.frequency_hz, config.phase_correct, config.enabled
    );

    // Disable when done
    gallo.pwm_disable(0).await.unwrap();
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void led_brightness(PicoDeGallo *gallo) {
    /* Configure channel 0: 1 kHz, no phase-correct */
    gallo_pwm_set_config(gallo, 0, 1000, false);

    /* Enable PWM output */
    gallo_pwm_enable(gallo, 0);

    /* Set 50% duty cycle */
    gallo_pwm_set_duty_cycle(gallo, 0, 32768);

    /* Read back duty info */
    GalloPwmDutyCycleInfo duty_info;
    gallo_pwm_get_duty_cycle(gallo, 0, &duty_info);
    printf("Duty: %u/%u\n", duty_info.current_duty, duty_info.max_duty);

    /* Read back configuration */
    GalloPwmConfigurationInfo config_info;
    gallo_pwm_get_config(gallo, 0, &config_info);
    printf("Frequency: %u Hz, Phase-correct: %d, Enabled: %d\n",
           config_info.frequency_hz, config_info.phase_correct,
           config_info.enabled);

    /* Disable when done */
    gallo_pwm_disable(gallo, 0);
}

HAL

The HAL crate exposes individual PWM channels as embedded_hal::pwm::SetDutyCycle implementors, allowing use with any driver that accepts the standard trait.

#![allow(unused)]
fn main() {
use pico_de_gallo_hal::Hal;
use embedded_hal::pwm::SetDutyCycle;

fn servo_control(hal: &Hal) {
    let mut pwm = hal.pwm_channel(0);

    // SetDutyCycle trait methods
    pwm.set_duty_cycle(32_768).unwrap();

    let max = pwm.max_duty_cycle();
    println!("Max duty: {max}");

    // 75% duty cycle
    pwm.set_duty_cycle(max * 3 / 4).unwrap();

    // Fully on / fully off
    pwm.set_duty_cycle_fully_on().unwrap();
    pwm.set_duty_cycle_fully_off().unwrap();

    // Percentage-based (if available via trait extension)
    pwm.set_duty_cycle_percent(50).unwrap();
}
}

Lib API Reference

All library methods are async and return Result types. The PicoDeGallo instance is created with PicoDeGallo::new() (which is not async).

MethodReturn Type
pwm_set_duty_cycle(channel: u8, duty: u16)Result<(), PicoDeGalloError<PwmError>>
pwm_get_duty_cycle(channel: u8)Result<PwmDutyCycleInfo, PicoDeGalloError<PwmError>>
pwm_enable(channel: u8)Result<(), PicoDeGalloError<PwmError>>
pwm_disable(channel: u8)Result<(), PicoDeGalloError<PwmError>>
pwm_set_config(channel: u8, frequency_hz: u32, phase_correct: bool)Result<(), PicoDeGalloError<PwmError>>
pwm_get_config(channel: u8)Result<PwmConfigurationInfo, PicoDeGalloError<PwmError>>

Response Types

PwmDutyCycleInfo

FieldTypeDescription
max_dutyu16Maximum duty cycle value (65535)
current_dutyu16Currently configured duty cycle

PwmConfigurationInfo

FieldTypeDescription
frequency_hzu32Configured PWM frequency in Hz
phase_correctboolWhether phase-correct mode is enabled
enabledboolWhether the channel is currently enabled

FFI Reference

All FFI functions follow the gallo_pwm_* naming convention and return a Status code.

Status gallo_pwm_set_duty_cycle(PicoDeGallo *gallo, uint8_t channel, uint16_t duty);
Status gallo_pwm_get_duty_cycle(PicoDeGallo *gallo, uint8_t channel, GalloPwmDutyCycleInfo *out_info);
Status gallo_pwm_enable(PicoDeGallo *gallo, uint8_t channel);
Status gallo_pwm_disable(PicoDeGallo *gallo, uint8_t channel);
Status gallo_pwm_set_config(PicoDeGallo *gallo, uint8_t channel, uint32_t frequency, bool phase_correct);
Status gallo_pwm_get_config(PicoDeGallo *gallo, uint8_t channel, GalloPwmConfigurationInfo *out_info);

Hardware Setup

Connect an LED (or other PWM-compatible load) to one of the PWM pins with a current-limiting resistor:

GPIO 12 (PWM 0) ── 330Ω ──┬── LED ── GND
                           │
GPIO 13 (PWM 1) ── 330Ω ──┘  (or separate LEDs)

For servo motors, connect the signal wire directly to a PWM pin and configure for the servo’s expected frequency (typically 50 Hz). Adjust the duty cycle to control the servo position.

ADC (Analog-to-Digital Converter)

Hardware revision note: ADC requires hw-rev2 firmware. On v1 hardware, ADC endpoints return AdcError::Unsupported.

Pico de Gallo exposes the RP2350’s ADC peripheral for single-shot analog reads. Four GPIO-based channels are available, each providing 12-bit resolution over a 0–3.3 V nominal input range.

Channel Mapping

ChannelGPIOEnum Variant
026Adc0
127Adc1
228Adc2
329Adc3

Constants

ConstantValueDescription
NUM_ADC_GPIO_CHANNELS4Number of GPIO-based ADC channels
ADC_RESOLUTION_BITS12Bits of resolution per sample
ADC_NOMINAL_REFERENCE_MV3300Nominal reference voltage in millivolts

Operations

OperationDescription
ReadReads a single 12-bit sample from the specified channel
InfoReturns ADC configuration (resolution, reference voltage, channel count)

Voltage Conversion

The ADC returns a raw 12-bit unsigned value (0–4095). Convert to millivolts with:

voltage_mv = (raw * 3300) / 4095

For example, a raw reading of 2048 corresponds to approximately 1649 mV.

Types

AdcChannel

#![allow(unused)]
fn main() {
enum AdcChannel {
    Adc0,
    Adc1,
    Adc2,
    Adc3,
}
}

AdcConfigurationInfo

#![allow(unused)]
fn main() {
struct AdcConfigurationInfo {
    resolution_bits: u8,
    nominal_reference_mv: u16,
    num_gpio_channels: u8,
}
}

AdcError

#![allow(unused)]
fn main() {
enum AdcError {
    ConversionFailed,
    Other,
    Unsupported,
}
}

CLI

# Read a single sample from channel 0
gallo adc read --channel 0

# Read from channel 2
gallo adc read --channel 2

# Show ADC configuration
gallo adc info

Example output for gallo adc read --channel 0:

ADC channel 0: raw 2048 (≈ 1649 mV)

Example output for gallo adc info:

ADC Configuration:
  Resolution:       12 bits
  Reference:        3300 mV
  GPIO channels:    4

Rust Library

All library methods are async. PicoDeGallo::new() is not async.

use pico_de_gallo_lib::{PicoDeGallo, AdcChannel};

fn main() {
    let gallo = PicoDeGallo::new().unwrap();

    smol::block_on(async {
        // Read a single sample from channel 0
        let raw = gallo.adc_read(AdcChannel::Adc0).await.unwrap();
        let voltage_mv = (raw as u32 * 3300) / 4095;
        println!("ADC0: raw {raw}, ~{voltage_mv} mV");

        // Query ADC configuration
        let config = gallo.adc_get_config().await.unwrap();
        println!(
            "Resolution: {} bits, Reference: {} mV, Channels: {}",
            config.resolution_bits,
            config.nominal_reference_mv,
            config.num_gpio_channels,
        );
    });
}

Error Handling

adc_read returns Result<u16, PicoDeGalloError<AdcError>>. The AdcError variants are:

  • ConversionFailed — the ADC hardware reported a conversion error.
  • Other — an unspecified ADC error.
  • Unsupported — ADC is not available on this hardware revision.
#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, AdcChannel, AdcError, PicoDeGalloError};

async fn read_adc(gallo: &PicoDeGallo) {
    match gallo.adc_read(AdcChannel::Adc1).await {
        Ok(raw) => println!("ADC1: {raw}"),
        Err(PicoDeGalloError::Endpoint(AdcError::ConversionFailed)) => {
            eprintln!("ADC conversion failed");
        }
        Err(e) => eprintln!("Unexpected error: {e:?}"),
    }
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void read_adc(PicoDeGallo *gallo) {
    uint16_t raw;
    GalloStatus rc = gallo_adc_read(gallo, ADC_CHANNEL_0, &raw);
    if (rc != GALLO_STATUS_OK) {
        fprintf(stderr, "ADC read failed: %d\n", rc);
        return;
    }

    uint32_t voltage_mv = ((uint32_t)raw * 3300) / 4095;
    printf("ADC0: raw %u, ~%u mV\n", raw, voltage_mv);
}

void adc_info(PicoDeGallo *gallo) {
    GalloAdcConfigurationInfo info;
    GalloStatus rc = gallo_adc_get_config(gallo, &info);
    if (rc != GALLO_STATUS_OK) {
        fprintf(stderr, "ADC config failed: %d\n", rc);
        return;
    }

    printf("Resolution: %u bits\n", info.resolution_bits);
    printf("Reference:  %u mV\n", info.nominal_reference_mv);
    printf("Channels:   %u\n", info.num_gpio_channels);
}

HAL

At the HAL layer the ADC is accessed directly on the Hal struct (no sub-object needed):

#![allow(unused)]
fn main() {
use pico_de_gallo_hal::Hal;
use pico_de_gallo_internal::AdcChannel;

fn read_adc(hal: &Hal) {
    let raw = hal.adc_read(AdcChannel::Adc0).unwrap();
    let voltage_mv = (raw as u32 * 3300) / 4095;
    println!("ADC0: raw {raw}, ~{voltage_mv} mV");

    let config = hal.adc_get_config().unwrap();
    println!("Resolution: {} bits", config.resolution_bits);
}
}

1-Wire Bus

Hardware revision note: 1-Wire requires hw-rev2 firmware. On v1 hardware, 1-Wire endpoints return OneWireError::Unsupported.

Pico de Gallo provides 1-Wire bus support through the RP2350’s PIO (Programmable I/O) state machine hardware. The 1-Wire data pin is on GPIO 16, configured in open-drain mode.

Operations

OperationDescription
ResetResets the bus and detects device presence
ReadReads N bytes from the bus
WriteWrites raw bytes to the bus
Write PullupWrites bytes then applies strong pullup for parasitic-power devices
SearchStarts a new ROM search and returns the first device
Search NextContinues the current ROM search

Direction determines the payload ceiling. onewire_read may return at most MAX_RESPONSE_PAYLOAD (1014 bytes). onewire_write and onewire_write_pullup may send at most MAX_TRANSFER_SIZE (4096 bytes). Every host surface checks these limits before transmitting and reports BufferTooLong for an over-ceiling call.

DS18B20 Temperature Sensor Example

The DS18B20 is the most popular 1-Wire device. Here’s how to read its temperature using each interface.

Protocol Refresher

  • Skip ROM (0xCC): addresses all devices (single-device bus)
  • Convert T (0x44): starts temperature conversion (needs 750ms with strong pullup for parasitic power)
  • Read Scratchpad (0xBE): reads 9 bytes of sensor data
  • Temperature is in bytes 0–1 (signed 16-bit, little-endian, 1/16°C resolution)

CLI

# 1. Discover devices on the bus
gallo onewire search

# 2. Reset the bus
gallo onewire reset

# 3. Start temperature conversion with 750ms strong pullup
gallo onewire write-pullup --data cc44 --duration 750

# 4. Reset again before reading
gallo onewire reset

# 5. Send Read Scratchpad command
gallo onewire write --data ccbe

# 6. Read 9-byte scratchpad
gallo onewire read --len 9

# Parse temperature from the first 2 bytes:
# e.g., bytes [50, 01] → 0x0150 = 336 → 336 / 16.0 = 21.0°C

Rust Library

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::PicoDeGallo;

async fn read_ds18b20_temperature(gallo: &PicoDeGallo) -> f32 {
    // Reset bus, check presence
    let present = gallo.onewire_reset().await.unwrap();
    assert!(present, "No device on bus");

    // Skip ROM + Convert Temperature, strong pullup for 750ms
    gallo.onewire_write_pullup(&[0xCC, 0x44], 750).await.unwrap();

    // Reset again
    gallo.onewire_reset().await.unwrap();

    // Skip ROM + Read Scratchpad
    gallo.onewire_write(&[0xCC, 0xBE]).await.unwrap();

    // Read 9-byte scratchpad
    let data = gallo.onewire_read(9).await.unwrap();

    // Temperature is in bytes 0–1 (little-endian, 12-bit signed fixed-point)
    let raw = i16::from_le_bytes([data[0], data[1]]);
    raw as f32 / 16.0
}
}

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

float read_ds18b20(PicoDeGallo *gallo) {
    bool present;
    gallo_onewire_reset(gallo, &present);
    if (!present) {
        fprintf(stderr, "No device on bus\n");
        return -999.0f;
    }

    // Skip ROM + Convert T with 750ms strong pullup
    uint8_t convert_cmd[] = {0xCC, 0x44};
    gallo_onewire_write_pullup(gallo, convert_cmd, 2, 750);

    // Reset before reading
    gallo_onewire_reset(gallo, &present);

    // Skip ROM + Read Scratchpad
    uint8_t read_cmd[] = {0xCC, 0xBE};
    gallo_onewire_write(gallo, read_cmd, 2);

    // Read 9-byte scratchpad
    uint8_t buf[9];
    uint16_t out_len;
    gallo_onewire_read(gallo, buf, 9, &out_len);

    // Temperature from bytes 0–1
    int16_t raw = (int16_t)(buf[0] | (buf[1] << 8));
    return raw / 16.0f;
}

HAL

#![allow(unused)]
fn main() {
use pico_de_gallo_hal::Hal;

fn read_ds18b20_blocking(hal: &Hal) -> f32 {
    let ow = hal.onewire();

    let present = ow.reset().unwrap();
    assert!(present, "No device on bus");

    ow.write_pullup(&[0xCC, 0x44], 750).unwrap();
    ow.reset().unwrap();
    ow.write(&[0xCC, 0xBE]).unwrap();
    let data = ow.read(9).unwrap();

    let raw = i16::from_le_bytes([data[0], data[1]]);
    raw as f32 / 16.0
}
}

Bus Scanning

To enumerate all devices on the 1-Wire bus:

gallo onewire search

Output:

Found 2 device(s):
  1: ROM ID 0x28FF123456780012 (family 0x28)
  2: ROM ID 0x28FF9ABCDE340056 (family 0x28)

The family code 0x28 identifies DS18B20 sensors. Each ROM ID is a unique 64-bit address: family code (1 byte) + serial number (6 bytes) + CRC (1 byte).

Hardware Setup

Connect a DS18B20 (or other 1-Wire device) to GPIO 16 with a 4.7kΩ pull-up resistor to 3.3V:

3.3V ──┬── 4.7kΩ ──┬── GPIO 16 (data)
       │            │
       │          DS18B20
       │            │
      GND ─────── GND

For parasitic power mode (no separate VDD), use the write-pullup command which drives the data line high after sending commands to supply power through the bus itself.

Transaction Batching

When talking to I2C or SPI devices, a single logical transaction often requires multiple bus operations — for example, writing a register address and then reading back its value. Without batching, each operation is a separate USB round-trip:

Host ──write──▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──ack──── USB ◂── Firmware ◂── I²C bus    (~1 ms)
Host ──read───▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──data─── USB ◂── Firmware ◂── I²C bus    (~1 ms)
                                            Total: ~4 ms

Transaction batching packs all operations into a single USB transfer and preserves one bus-level transaction. For I2C, the firmware sends one START before the first operation, repeated STARTs only when the direction changes, and one STOP after the last operation. It returns all results at once:

Host ──[write, read]──▸ USB ──▸ Firmware ──▸ I²C bus    (~1 ms)
Host ◂──[data]──────── USB ◂── Firmware ◂── I²C bus    (~1 ms)
                                            Total: ~2 ms

For transactions with many operations, this is a 10–50× speedup — USB latency dominates, not bus time.

Using Batched Transactions from the CLI

The gallo CLI exposes batch operations directly. Each --op flag specifies one bus operation.

I2C Batch

Write a register address, then read back 2 bytes:

$ gallo i2c batch -a 0x48 --op write:0x00 --op read:2
Read data (2 bytes):
  0000: 19 80                                              ..

Send a two-byte EEPROM address and three data bytes as one gather write:

$ gallo i2c batch -a 0x50 --op write:0x00,0x10 --op write:0xab,0xcd,0xef
Batch complete (no read data)

The adjacent writes above produce the byte sequence 00 10 ab cd ef under one address phase; there is no STOP or repeated START between them. In general, the operations execute as a single I2C transaction: the bus is not released until the batch completes, and each direction change (write-to-read or read-to-write) produces a documented repeated START and re-addressing. This requires firmware from schema 0.7 or newer. Older firmware executes every operation as a separate transaction.

Available I2C operations

OperationSyntaxDescription
Readread:NRead N bytes from the device
Writewrite:B1,B2,...Write the given bytes (hex 0x.. or decimal)

SPI Batch

Read a JEDEC ID from a SPI flash (command 0x9F, 3-byte response):

$ gallo spi batch --cs 0 --op write:0x9f --op read:3
Read data (3 bytes):
  0000: ef 40 18                                           .@.

Full-duplex transfer followed by a delay:

$ gallo spi batch --cs 1 --op transfer:0x01,0x02,0x03 --op delay:1000 --op read:4
Read data (7 bytes):
  0000: ff ff ff 00 00 00 00                               .......

The --cs flag specifies which GPIO pin is used as chip-select. Valid values are 0..num_gpios, where num_gpios is the count the connected device reports in device/info — not a fixed range baked into the CLI. gallo validates the firmware on startup and checks --cs against that reported count before parsing the operations or transmitting anything, so an out-of-range chip-select drives no pin. A device reporting zero GPIOs produces its own distinct error rather than an out-of-range one.

The firmware asserts CS low before the first operation and deasserts it after the last — all operations run atomically under chip-select.

Available SPI operations

OperationSyntaxDescription
Readread:NClock in N bytes (MISO only)
Writewrite:B1,B2,...Clock out the given bytes (MOSI only)
Transfertransfer:B1,B2,...Full-duplex: send on MOSI, receive same count on MISO
DelayNsdelay:NSDelay for NS nanoseconds (best-effort, firmware resolution)

Using the Lib Crate Directly

If you need batch transactions from Rust code, use the i2c_batch and spi_batch methods on the PicoDeGallo client. These accept typed operation slices (&[I2cBatchOp] / &[SpiBatchOp]) directly — no manual encoding needed.

I2C batch example

use pico_de_gallo_lib::{PicoDeGallo, I2cBatchOp};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    // Build a "write register pointer, then read 2 bytes" transaction
    let ops = [
        I2cBatchOp::Write { data: &[0x00] },       // pointer register
        I2cBatchOp::Read { len: 2 },                // read temperature
    ];

    let result = pg.i2c_batch(0x48, &ops).await?;
    let temp_raw = u16::from_be_bytes([result[0], result[1]]);
    let celsius = (temp_raw >> 4) as f32 * 0.0625;
    println!("Temperature: {celsius:.2} °C");

    Ok(())
}

SPI batch example

use pico_de_gallo_lib::{PicoDeGallo, SpiBatchOp};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    // Read JEDEC ID: send command 0x9F, then read 3 bytes
    let ops = [
        SpiBatchOp::Write { data: &[0x9F] },
        SpiBatchOp::Read { len: 3 },
    ];

    let result = pg.spi_batch(0, &ops).await?;
    println!(
        "JEDEC ID: manufacturer=0x{:02x}, type=0x{:02x}, capacity=0x{:02x}",
        result[0], result[1], result[2]
    );

    Ok(())
}

Transparent Batching via the HAL Crate

The most powerful aspect of transaction batching is that you don’t need to use it explicitly. The HAL crate’s embedded-hal trait implementations use batch endpoints automatically.

When you call I2c::transaction() or SpiDevice::transaction() from the HAL crate, the implementation encodes all operations into a single batch request, sends it over USB, and unpacks the results — all transparently. The firmware also preserves the corresponding bus-level transaction: I2C operations follow the START, repeated-START, and STOP rules above, while SPI operations remain under one chip-select assertion.

This means any existing device driver that uses the standard embedded-hal transaction API gets the bus-level transaction semantics and the performance benefit for free:

#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;
use embedded_hal::i2c::Operation;
use pico_de_gallo_hal::Hal;

fn read_tmp102(hal: &Hal) -> Result<f32, Box<dyn std::error::Error>> {
    let mut i2c = hal.i2c();
    let mut buf = [0u8; 2];

    // This is one USB round-trip and one I2C transaction
    i2c.transaction(
        0x48,
        &mut [
            Operation::Write(&[0x00]),   // set pointer to temperature register
            Operation::Read(&mut buf),   // read 2-byte temperature
        ],
    )?;

    let raw = u16::from_be_bytes(buf);
    Ok((raw >> 4) as f32 * 0.0625)
}
}

Similarly, SpiDevice::transaction() batches all SPI operations and manages chip-select automatically:

#![allow(unused)]
fn main() {
use embedded_hal::spi::SpiDevice;
use embedded_hal::spi::Operation;
use pico_de_gallo_hal::Hal;

fn read_spi_jedec(hal: &Hal) -> Result<[u8; 3], Box<dyn std::error::Error>> {
    // `spi_device` returns a Result: the chip-select is checked against
    // the device-reported GPIO count before the pin is driven, so an
    // out-of-range pin is refused here rather than reconfigured.
    let mut spi = hal.spi_device(0)?;  // CS on GPIO 0
    let mut id = [0u8; 3];

    // One USB round-trip: CS asserted, command sent, ID read, CS deasserted
    spi.transaction(&mut [
        Operation::Write(&[0x9F]),
        Operation::Read(&mut id),
    ])?;

    Ok(id)
}
}

Before vs. After

Consider a logical bus transaction containing three operations:

ApproachUSB Round-TripsApprox. Latency
Without batching (3 × write/read)6~6 ms
With batching (1 × batch)2~2 ms

The latency improvement grows with the number of operations. Unlike separate calls, the batch also preserves one bus-level transaction.

Wire Format Details

For those interested in the protocol internals, batch operations use postcard serialization. Each I2cBatchOp or SpiBatchOp is serialized individually using postcard::to_slice, and the resulting bytes are concatenated into the ops field. The firmware decodes them one at a time using postcard::take_from_bytes.

I2C operation encoding (postcard)

VariantEncoding
Read { len }varint 0 (variant index) + varint len
Write { data }varint 1 + varint data length + raw bytes

SPI operation encoding (postcard)

VariantEncoding
Read { len }varint 0 + varint len
Write { data }varint 1 + varint data length + raw bytes
Transfer { data }varint 2 + varint data length + raw bytes
DelayNs { ns }varint 3 + varint ns

The count field in each batch request struct tells the firmware how many operations to expect, providing an additional safety check during decoding.

The response for both I2C and SPI is simply the concatenated read (and transfer) data. The host already knows the expected lengths from the request, so no framing is needed in the response.

Limits

ParameterValue
Maximum operations per batch64 (MAX_BATCH_OPS)
Maximum size of an individual operationNone — bounded only by the totals below
Total bytes a batch may return1014 (MAX_RESPONSE_PAYLOAD)
Total bytes a batch may sendBounded by the whole request frame: 5119 bytes (MAX_REQUEST_FRAME), header and encoding overhead included — see The request-frame ceiling
Protocol packet-buffer/argument bound4096 bytes (MAX_TRANSFER_SIZE)
Plain read/duplex endpoint bound1014 bytes (MAX_RESPONSE_PAYLOAD)
Plain write endpoint bound4096 bytes (MAX_TRANSFER_SIZE)
Direct I²C RPC measurementsRead: 1014 bytes after a 1-byte write; write: no failure through 4096 bytes
Batch measurementsReads totalling 1014 bytes return in full; 1015 is refused with BufferTooLong, on both i2c/batch and spi/batch. A request frame of 5119 bytes is executed; 5120 is refused with BufferTooLong
Demonstrated SPI payload (send direction)Shape-dependent and below 4096; no general ceiling is published
Zero-length I²C writesRejected with ZeroLengthWrite before bus access

If a batch violates these constraints, the firmware returns an error indicating which constraint was violated. Validation failures report the exact zero-indexed operation; an I2C bus failure applies to the atomic transaction as a whole and reports operation index 0. An aggregate-length overflow is not attributable to any single operation, so it reports index 0 as well.

MAX_TRANSFER_SIZE is a buffer and argument bound, not a guarantee that 4096 bytes of application data fit through postcard-rpc and COBS framing. Request shape and response size both matter. The direct-RPC I²C measurements used i2c/write and i2c/write-read; the batch row was measured separately, through i2c/batch and spi/batch. See the measured I²C and SPI evidence in Troubleshooting.

Issue #179 introduced the aggregate returned-data bound for batches. Issue #158 applies the same directional rule to the plain endpoints and every host surface: reads and full-duplex transfers use MAX_RESPONSE_PAYLOAD, while send-only writes use MAX_TRANSFER_SIZE. Issue #186 completed the pair by bounding a batch’s aggregate outgoing bytes against MAX_REQUEST_FRAME. In every case an over-ceiling call is refused before transmission.

The response ceiling

MAX_RESPONSE_PAYLOAD bounds what a batch may send back, not what the firmware can buffer. For I2C only Read counts towards it; for SPI, Read and Transfer both do, because a transfer returns as many bytes as it sends. Write and DelayNs contribute nothing.

The number is a property of the host transport rather than of the device: a postcard-rpc response frame is read into a 1024-byte buffer, of which 7 bytes are the header, 1 is the Result discriminant, and 2 are the payload’s length prefix. The firmware enforces it anyway. Until this was fixed, a batch whose reads totalled between 1015 and 4096 bytes was accepted and executed — for I2C the whole transaction ran, including every Write; for SPI chip-select was asserted and deasserted and the clock was driven — and only then lost its response to truncation. The caller received Comms(Postcard(DeserializeUnexpectedEnd)), which looks like a transport fault and invites a retry that repeats every write.

Refusing up front costs the 1015–4096 range, which never worked anyway, and buys the guarantee that a batch the device accepts is a batch whose result the caller can actually see. Split larger reads across several batches.

The request-frame ceiling

MAX_REQUEST_FRAME (5119) bounds what a batch may send out, and it is the only thing that does. A batch’s Write (and SPI Transfer) bytes all travel in one request frame, and that frame has to fit in the firmware’s 5120-byte receive buffer, less one byte that an exactly-filling frame cannot use. Wire Protocol derives both numbers term by term.

Unlike the response ceiling, this one is not the firmware’s to enforce. An over-ceiling frame is discarded by postcard-rpc’s receiver before any handler runs, so the device cannot refuse what it never sees. The bound is applied host-side, before transmission, in check_i2c_batch_ops and check_spi_batch_ops; an over-ceiling batch is rejected with BufferTooLong and failed_op = 0, the same aggregate-overflow convention the response ceiling uses.

It bounds the aggregate, not any single operation. Two consequences worth stating plainly:

  • A batch of individually modest writes can still overrun it. The hardware measurement below tripped it with eight 635-byte writes, none of them anywhere near MAX_TRANSFER_SIZE.
  • Conversely, a single batch Write is allowed past MAX_TRANSFER_SIZE, which a plain i2c_write would refuse. That is deliberate: a batch write streams straight out of the received frame and never enters the firmware’s 4096-byte scratch buffer, so the argument bound does not apply to it. The frame is its real limit.

Sizing a batch exactly means accounting for postcard overhead — a variant byte and a length varint per operation, plus a header, a selector byte and two more varints for the request. pico-de-gallo-lib exports i2c_batch_request_frame_len and spi_batch_request_frame_len so callers can compute it rather than model it. Callers who would rather not: keep a batch’s total payload comfortably under 5000 bytes, or split it.

Why the bound is slightly conservative

The frame’s header is 13 bytes until the client has received its first reply and 7 bytes afterwards, because postcard-rpc narrows the key width only once a reply comes back (see Wire Protocol). The host cannot ask which state it is in — the field is private to postcard-rpc — so the bound assumes the wider header. On a connection that has already received a reply, six more payload bytes would in fact have fitted.

That is the price of an answer that does not depend on connection state. The alternative is worse: a bound computed from the narrow header would accept a batch that is silently dropped whenever it happens to be the first request a process sends.

Measurements

Measured through pico-de-gallo-lib’s i2c_batch on board 49742081C885AC69 (hw-rev2), one Write operation against a non-responding address, from a fresh process so the header is the wide one:

PayloadEncoded opsRequest frameResult before #186Result now
509951025119NoAcknowledge — the batch ranunchanged
510051035120Timeout after 5 s — the frame never arrivedBufferTooLong, immediately

Re-measured on firmware firmware-v0.11.0-88-gfcb38de3a1ff for issue #186, with the host guard deliberately stubbed out to exercise the device alone; the edge was identical to the original -79-gc3c6a3e07bec run. The multi-operation arm, eight writes against the same address, tripped at 635 bytes each and passed at 634.

The pre-#186 failure was worse on SPI than on I²C. A batch carrying no DelayNs operation is bounded by the firmware’s 30-minute handler ceiling rather than the ordinary call timeout, so an over-ceiling spi/batch left the caller waiting over half an hour — confirmed still running after 90 seconds in the same control arm — for a request the device had never received. Nothing was driven in either case: the bus stayed idle and chip-select never moved, which is why this is a diagnosability defect rather than a corruption one.

Crate Map

Pico de Gallo is split into small crates on purpose. You can start at the surface that matches your language, and if you need to go deeper, the layers stack cleanly.

At a high level:

  • pico-de-gallo-internal defines the wire protocol shared by host code and firmware.
  • pico-de-gallo-lib is the async Rust client that speaks that protocol over USB with tokio + nusb.
  • pico-de-gallo-hal turns that client into embedded-hal / embedded-hal-async traits.
  • pico-de-gallo-ffi exports a stable C ABI as a cdylib.
  • pyco-de-gallo exposes the same device to Python via PyO3 + maturin.
  • gallo is the CLI built on top of the Rust library.
  • gallo-mcp is an MCP server exposing the device to AI agents over stdio.
  • pico-de-gallo-firmware is the RP2350 no_std firmware running on the board.

Note

pico-de-gallo-firmware lives in a separate Cargo workspace from the host crates. That split is deliberate: firmware targets thumbv8m.main-none-eabihf and shares only the wire types with the host side.

Dependency Direction

                         You interact here

+---------------------+   +--------------------+   +-------------------+
| gallo               |   | pyco-de-gallo      |   | pico-de-gallo-ffi |
| CLI                 |   | Python bindings    |   | C ABI / cdylib    |
+----------+----------+   +----------+---------+   +---------+---------+
           \                         |                       /
            \                        |                      /
             +-----------------------+---------------------+
                                     |
                                     v
                       +-------------------------------+
                       | pico-de-gallo-lib             |
                       | async host client             |
                       | tokio + nusb + postcard-rpc   |
                       +---------------+---------------+
                                       |
                    +------------------+------------------+
                    |                                     |
                    v                                     v
      +-------------------------------+    +-------------------------------+
      | pico-de-gallo-hal             |    | pico-de-gallo-internal        |
      | embedded-hal bridge           |    | wire protocol types           |
      +-------------------------------+    +---------------+---------------+
                                                           |
                                                           v
                                      +-----------------------------------+
                                      | pico-de-gallo-firmware            |
                                      | RP2350 no_std firmware            |
                                      +-----------------------------------+

The important line is the one between pico-de-gallo-internal and firmware: that crate is the contract. If request or response types change there, the host and firmware must move together.

What Each Crate Is For

CrateWhat it gives youTypical use
pico-de-gallo-internalShared protocol types, endpoint definitions, schema versionBuilding host or firmware layers on top of the wire protocol
pico-de-gallo-libTyped async Rust APIWriting Rust host tools and applications
pico-de-gallo-halembedded-hal and embedded-hal-async traits backed by USBRunning driver crates on your laptop without reflashing firmware
pico-de-gallo-ffiC-compatible shared library and generated headerUsing Pico de Gallo from C, C++, Zig, or other FFI-friendly languages
pyco-de-galloPython module pyco_de_galloQuick experiments, test scripts, notebooks, lab automation
galloCommand-line utilityInteractive bring-up, one-off reads/writes, smoke tests, scripting
gallo-mcpMCP server (stdio) exposing one tool per peripheral opLetting AI coding agents probe and drive real hardware
pico-de-gallo-firmwareDevice-side implementation for the RP2350Flashing the board, adding endpoints, changing hardware behavior

Which crate do I want?

If you want to…Start here
Probe hardware from a shellgallo
Let an AI agent drive hardwaregallo-mcp
Write a Rust host toolpico-de-gallo-lib
Test an embedded-hal driver on your laptoppico-de-gallo-hal
Call Pico de Gallo from C or C++pico-de-gallo-ffi
Script from Pythonpyco-de-gallo
Change the protocol itselfpico-de-gallo-internal
Change what runs on the boardpico-de-gallo-firmware

If you are not sure, start with gallo for exploration, move to pico-de-gallo-lib for Rust applications, and reach for pico-de-gallo-hal when you want real driver code to run unchanged on the host.

gallo CLI

gallo is the fastest way to prove your board works, poke a device, and turn a manual experiment into a repeatable command. It sits on top of pico-de-gallo-lib, so the CLI and the Rust library speak the same protocol and see the same capabilities.

Use it for:

  • bring-up and smoke tests,
  • one-off I2C / SPI / UART / GPIO / PWM / ADC / 1-Wire operations,
  • shell scripting,
  • discovering which board is which when several are plugged in.

Top-level Help

$ gallo -h
Access I2C/SPI devices through Pico De Gallo

Usage: gallo [OPTIONS] <COMMAND>

Commands:
  list     List all connected Pico de Gallo devices
  ping     Check device liveness with a round-trip echo
  version  Get firmware version
  i2c      I2C access methods
  spi      SPI access methods
  gpio     GPIO access methods
  uart     UART access methods
  pwm      PWM control methods
  adc      ADC access methods
  onewire  1-Wire bus access methods
  help     Print this message or the help of the given subcommand(s)

Options:
  -s, --serial-number <SERIAL_NUMBER>  Select a specific board by USB serial number
  -f, --format <FORMAT>                Output format for read data [default: hex] [possible values: hex, binary, ascii]
  -h, --help                           Print help (see more with '--help')
  -V, --version                        Print version

Tip

--help prints the same thing with each option expanded — including the per-value descriptions for --format.

Global Options

-s, --serial-number

If more than one Pico de Gallo is attached, gallo would otherwise use the first matching device the OS reports. Pass -s to make board selection explicit.

$ gallo list
Serial Number         Bus    Address
E6633861A34B8C24      2      14
E6633861A34B9F17      1      8

Then target one of those serials explicitly:

gallo -s E6633861A34B9F17 version

-f, --format hex|binary|ascii

The global -f flag controls how read-style commands print data:

  • hex — hexadecimal bytes,
  • binary — raw bytes to stdout,
  • ascii — printable characters, with non-printable bytes shown as ..
$ gallo -f ascii uart read --count 5 --timeout 100
Hello

Tip

binary is the right choice when you want to pipe the output into another program without pretty-printing in the way.

Device Discovery Commands

list

Lists every connected Pico de Gallo device the host can see.

$ gallo list
Serial Number         Bus    Address
E6633861A34B8C24      2      14

version

Queries the connected board for firmware, schema, hardware revision, runtime GPIO count, build identity, and capabilities.

$ gallo version
╭─────────────┬──────────────────────────────╮
│ Firmware    │ v0.12.0                      │
│ Schema      │ v0.8.0                       │
│ HW revision │ 2                            │
│ GPIOs       │ 4                            │
│ Build       │ firmware-v0.12.0-42-g1a2b3c4 │
╰─────────────┴──────────────────────────────╯
╭─────┬─────┬──────┬──────┬─────┬─────┬────────╮
│ I2C │ SPI │ UART │ GPIO │ PWM │ ADC │ 1-Wire │
├─────┼─────┼──────┼──────┼─────┼─────┼────────┤
│ ✓   │ ✓   │ ✓    │ ✓    │ ✓   │ ✓   │ ✓      │
╰─────┴─────┴──────┴──────┴─────┴─────┴────────╯

ping

Round-trips a random u32 through the firmware’s ping endpoint and checks that the same value comes back.

$ gallo ping
Ping OK

This is the lowest-level check gallo offers. It exercises USB enumeration, the postcard-rpc framing, and the firmware dispatch loop without touching a peripheral, so it is the right first move when a board enumerates but a peripheral command misbehaves.

The payload is randomised per invocation so that a stale, duplicated, or default-initialised response cannot pass as a healthy round trip. If the round trip completes but the value comes back wrong, gallo reports the mismatch with both values rather than a generic transport error:

$ gallo ping
Error: ping echo mismatch: sent 0x9f2c41ab, received 0x00000000

Note

ping and version are the only device subcommands that skip the up-front schema-version check. A board whose schema does not match this gallo build should still be able to prove its USB path works — that is exactly the situation ping exists to diagnose. See Verifying Your Device.

Peripheral Command Groups

i2c

SubcommandPurpose
scanProbe the bus for responding addresses
readRead bytes from one target address
writeWrite bytes to one target address
write-readWrite first, then read from the same target without releasing the bus
set-configSet the I2C frequency
get-configShow the active I2C frequency
batchExecute multiple I2C operations as a single transaction

See the I2C chapter and Transaction Batching for examples.

spi

SubcommandPurpose
readClock in bytes
writeClock out bytes
transferFull-duplex SPI transfer
write-readHalf-duplex write followed by read
set-configSet frequency and SPI mode (0–3)
get-configShow the active SPI configuration
batchRun atomic multi-step SPI transactions under chip-select

Note

The CLI refuses over-ceiling payloads locally, before transmitting. Data returned by the device is limited to MAX_RESPONSE_PAYLOAD (1014 bytes), while data sent to it is limited to MAX_TRANSFER_SIZE (4096 bytes). Full-duplex spi transfer is therefore limited to 1014 bytes even though spi write accepts 4096. A batch is bounded a third way: its aggregate outgoing bytes must fit one MAX_REQUEST_FRAME (5119-byte) request frame. The command reports BufferTooLong; see troubleshooting.

The read counts accepted by i2c read -c, i2c write-read -c, spi read -c, and spi write-read -c are u16 values. Values above 65535 are rejected by argument parsing rather than wrapping during conversion; the lower MAX_RESPONSE_PAYLOAD limit is then enforced before transmission.

batch --cs <PIN> accepts any u8. The pin is checked at run time against the GPIO count the connected device reports — not against a fixed range — before the operations are parsed and before anything is transmitted, so an out-of-range chip-select drives no pin:

invalid SPI chip-select pin 7; device reports 4 GPIOs (valid 0..4)
device reports num_gpios=0; no SPI chip-select pin is available

Every subcommand except list and version validates the firmware before doing anything else, and that validation supplies the count. If it fails — including a device/info timeout after 300 seconds — the error appears under firmware validation failed, never as an invalid chip-select.

See the SPI chapter and Transaction Batching.

gpio

SubcommandPurpose
getRead the current level of a pin
putDrive a pin high or low - put --pin <PIN> --level <high|low>
set-configSet direction and pull resistor
monitorSubscribe to edge events until you stop the process

See the GPIO chapter.

uart

SubcommandPurpose
readRead bytes with a timeout
writeWrite raw bytes
flushWait for the transmit buffer to drain
set-configSet baud rate, data bits, parity, and stop bits
get-configShow the active UART configuration
Usage: gallo uart set-config [OPTIONS] --baud-rate <BAUD_RATE>

Options:
      --baud-rate <BAUD_RATE>   Baud rate in bits per second (e.g. 9600, 115200)
      --data-bits <DATA_BITS>   Data bits per character [default: 8]
                                [possible values: 5, 6, 7, 8]
      --parity <PARITY>         Parity mode [default: none]
                                [possible values: none, odd, even, mark, space]
      --stop-bits <STOP_BITS>   Stop bits [default: 1]
                                [possible values: 1, 2]
  -h, --help                    Print help

The framing options are long-only. set-config replaces the complete configuration, so omitting them selects 8N1 and overwrites the active framing; repeat all three when changing only the baud rate. Baud and framing are applied together but not atomically: the divisor changes first and neither direction is drained, so quiesce transmit and receive traffic while reconfiguring.

See the UART chapter.

pwm

SubcommandPurpose
set-dutySet a raw duty-cycle value
get-dutyRead current and maximum duty
enableEnable the slice behind a channel
disableDisable the slice behind a channel
set-configSet frequency and phase-correct mode
get-configShow the active PWM configuration

See the PWM chapter.

adc

SubcommandPurpose
readRead one ADC sample
infoShow ADC resolution, reference, and channel count

See the ADC chapter.

onewire

SubcommandPurpose
resetReset the bus and report presence
readRead raw bytes
writeWrite raw bytes
write-pullupWrite, then hold the line high for parasitic-power devices
searchEnumerate ROM IDs on the bus

See the 1-Wire chapter.

A Few Crisp Examples

$ gallo ping
$ gallo i2c get-config
$ gallo spi get-config
$ gallo uart set-config --baud-rate 115200 --data-bits 8 \
    --parity none --stop-bits 1
$ gallo gpio monitor --pin 0 --edge rising
$ gallo adc read --channel 0
$ gallo onewire search

That is the right mental model for gallo: short commands, explicit arguments, and results you can immediately paste into a shell script or lab notebook.

pico-de-gallo-lib

pico-de-gallo-lib is the main Rust host library. It gives you a typed async client, PicoDeGallo, for every endpoint exposed by the firmware.

If you are writing a Rust application, this is usually the crate you want. gallo, the HAL crate, the FFI crate, and the Python bindings all build on top of it.

Connection Model

PicoDeGallo::new() and PicoDeGallo::new_with_serial_number() are synchronous constructors. They enumerate USB when called and panic if no matching board is present or the interface cannot be claimed; the fallible PicoDeGallo::try_new() and PicoDeGallo::try_new_with_serial_number() return a Result<PicoDeGallo, String> instead. They do not perform an async handshake up front — once constructed, the client completes the connection in the background and per-RPC calls (not the constructor) fail if the link drops later.

That gives you a simple startup story:

  • create the client synchronously,
  • call async methods for real work,
  • optionally validate() once if you want a strict compatibility check.

Constructors and discovery

ItemWhat it does
PicoDeGallo::new()Targets the first matching board the host sees
PicoDeGallo::new_with_serial_number(serial)Targets one specific board by USB serial
PicoDeGallo::try_new()Fallible new() — Err instead of panic when absent/unclaimable
PicoDeGallo::try_new_with_serial_number(serial)Fallible new_with_serial_number()
list_devices()Returns DeviceDescription values for every attached board
wait_closed().awaitResolves when the underlying USB connection closes
use pico_de_gallo_lib::{PicoDeGallo, list_devices};

fn main() {
    for dev in list_devices() {
        println!(
            "serial={:?} manufacturer={:?} product={:?}",
            dev.serial_number,
            dev.manufacturer,
            dev.product,
        );
    }

    let _first = PicoDeGallo::new();
    let _named = PicoDeGallo::new_with_serial_number("E6633861A34B8C24");
}

Minimal Example

use pico_de_gallo_lib::PicoDeGallo;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let gallo = PicoDeGallo::new();

    let echoed = gallo.ping(0x1234_5678).await?;
    println!("ping: 0x{echoed:08x}");

    let version = gallo.version().await?;
    println!(
        "firmware v{}.{}.{}",
        version.major,
        version.minor,
        version.patch,
    );

    Ok(())
}

Note

The library is async because USB I/O is async. The constructor is not. Put the client inside your async application and await the operations that actually hit the device.

Error Model

Most methods return Result<T, PicoDeGalloError<E>>.

That split is deliberate:

  • PicoDeGalloError::Comms(...) means the transport failed: disconnect, wire decode issue, closed connection, and similar host-side problems.
  • PicoDeGalloError::Endpoint(E) means the request made it to firmware and the endpoint itself reported an error.
  • PicoDeGalloError::Timeout { waited } means the request was transmitted but no reply arrived within this call’s bound. See Call Timeouts.

The endpoint-specific E is one of the protocol error enums:

  • I2cError
  • SpiError
  • UartError
  • GpioError
  • PwmError
  • AdcError
  • OneWireError
  • plus I2cBatchError / SpiBatchError for batched operations

That means you can match exactly the layer you care about:

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{I2cError, PicoDeGallo, PicoDeGalloError};

async fn read_sensor(gallo: &PicoDeGallo) {
    match gallo.i2c_read(0x48, 2).await {
        Ok(bytes) => println!("got {bytes:?}"),
        Err(PicoDeGalloError::Endpoint(I2cError::NoAcknowledge)) => {
            eprintln!("device did not ACK");
        }
        Err(PicoDeGalloError::Comms(_)) => {
            eprintln!("USB or transport problem");
        }
        Err(err) => eprintln!("other error: {err}"),
    }
}
}

Call Timeouts

Every RPC is bounded. Without a bound, a request whose response is never produced parks the caller forever, because there is no lower layer that gives up. That is not hypothetical: a request frame larger than the firmware’s receive buffer is dropped before the dispatcher ever sees it, so the board stays perfectly healthy — measured answering 40 of 40 concurrent pings — while the call waits indefinitely.

The default is DEFAULT_CALL_TIMEOUT, 5 seconds, roughly twice the slowest measured legitimate operation (a 4096-byte 1-Wire read, about 2.4 s).

Calls that can legitimately take longer are not bounded by that default. They derive their own bound from what the firmware itself allows, plus the configured call timeout as transport slack:

CallFirmware-side term
i2c_scanUNDECLARED_DISPATCH_BUDGET_MS (10 s) — it probes up to 128 addresses
uart_readthe caller’s timeout_ms
gpio_wait_*_with_timeoutthe caller’s timeout_ms
gpio_wait_* (no timeout)MAX_HANDLER_TIMEOUT_MS (30 min)
onewire_write_pullupthe caller’s pullup_duration_ms
spi_batchthe sum of its DelayNs operations
device_info, validateDEVICE_INFO_TIMEOUT (300 s)

Note that a timeout_ms of 0 selects the firmware’s 30-minute ceiling rather than meaning “wait forever”; the host mirrors that clamp exactly.

Override the default with with_call_timeout, which also widens every derived bound:

``rust,no_run use pico_de_gallo_lib::PicoDeGallo; use std::time::Duration;

let gallo = PicoDeGallo::try_new()?.with_call_timeout(Duration::from_secs(30));

Ok::<(), String>(())

``

A timed-out call leaves the handle usable: abandoning it drops the reply waiter, and sequence numbers are never reused, so a late reply is discarded rather than mismatched onto a later call.

Concurrency caveat

Firmware dispatch is serial — one handler runs at a time. A call issued while another is in flight waits for that one to finish before its own handler starts, and the host cannot observe when that happens.

So if you share one handle across tasks and one of them issues a long call (a gpio_wait_*, or an spi_batch carrying large DelayNs operations), concurrent calls can exceed the default through no fault of the device.

Sequential use — which is what the embedded-hal implementations produce, and the overwhelmingly common pattern — is unaffected. If you do drive a board concurrently alongside long calls, raise the bound.

validate() and Schema Compatibility

validate().await is the strict compatibility gate.

It calls the device/info endpoint and checks the firmware’s schema version against the host library’s compiled-in schema version from pico-de-gallo-internal.

Pre-1.0, schema minor version must match. If host and firmware were built against different wire schemas, validate() fails instead of letting you debug mysterious decoding problems later.

validate() returns the DeviceInfo on success, so you can immediately inspect firmware version, hardware revision, capability bits, and the informational firmware identity through DeviceInfo::build_id(). The build identity answers which image is running, but validate() deliberately ignores it: only schema major/minor compatibility determines whether validation succeeds.

The device/info round-trip is bounded at 300 seconds. On expiry validate() returns ValidateError::Timeout, which is deliberately distinct from ValidateError::Comms: nothing failed at the transport layer, the request is simply still outstanding, so there is no transport error to carry. The bound is generous because firmware dispatch is serial and a legal maximum-length SPI batch can occupy it for about 275 seconds; the point is that the wait is finite, not that it is short.

use pico_de_gallo_lib::PicoDeGallo;

#[tokio::main]
async fn main() {
    let gallo = PicoDeGallo::new();

    match gallo.validate().await {
        Ok(info) => println!(
            "fw {}.{}.{} schema {}.{}.{} hw={} capabilities={:?}",
            info.fw_major,
            info.fw_minor,
            info.fw_patch,
            info.schema_major,
            info.schema_minor,
            info.schema_patch,
            info.hw_version,
            info.capabilities,
        ),
        Err(err) => eprintln!("compatibility check failed: {err}"),
    }
}

The failure modes are explicit:

  • ValidateError::Comms — the host could not talk to the device,
  • ValidateError::LegacyFirmware — firmware is too old for device/info,
  • ValidateError::SchemaMismatch — host and firmware do not agree on the wire schema.

num_gpios() and the SPI Chip-Select Bound

num_gpios().await returns the GPIO count the connected device reports. That is the runtime-authoritative bound for a chip-select index; prefer it over the compile-time NUM_GPIOS, which is only this build’s default.

The value is resolved lazily. A cache miss performs one implicit validate() — so the same 300-second bound and the same schema check apply — and stores the result. Handles cloned from the same connection share that cache, so a warm lookup costs no USB traffic. A failed lookup is not cached, so the next call retries. A reported count of zero is a legitimate, cacheable answer rather than a miss.

spi_batch(cs_pin, ops) uses that bound to refuse a bad chip-select before it encodes anything or transmits an spi/batch request, and returns SpiBatchCallError:

VariantMeaning
DeviceInfo(ValidateError)The count could not be established — transport, 300-second timeout, legacy firmware, or schema mismatch. Never a chip-select complaint.
NoGpiosThe device reports zero GPIOs. Distinct from an out-of-range index.
InvalidCsPin { cs, num_gpios }cs is at or beyond the reported count. Carries the caller’s index verbatim.
Comms(HostErr)The spi/batch request itself failed at the transport layer.
Endpoint(SpiBatchError)The firmware executed the batch and refused or failed an operation, with failed_op.

A local refusal transmits nothing, drives no pin, and never fabricates a failed_op index. A cached count belongs to the handle that learned it: if the board is unplugged, that handle keeps the byte and a plausible-looking request will reach the batch RPC and fail with Comms. The client never rebinds itself to a different board, and a freshly constructed handle starts cold.

GPIO Topic Subscriptions

GPIO edge events are push-based topics, not request/response endpoints.

The flow is:

  1. open a host-side subscription with subscribe_gpio_events(depth).await,
  2. tell firmware which pin to monitor with gpio_subscribe(pin, edge).await,
  3. receive GpioEvent values from the returned MultiSubscription<GpioEvent>,
  4. call gpio_unsubscribe(pin).await when you are done.
use pico_de_gallo_lib::{GpioEdge, PicoDeGallo};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let gallo = PicoDeGallo::new();
    let mut events = gallo.subscribe_gpio_events(16).await?;

    gallo.gpio_subscribe(0, GpioEdge::Any).await?;

    if let Ok(event) = events.recv().await {
        println!("pin {} -> {:?}", event.pin, event.edge);
    }

    gallo.gpio_unsubscribe(0).await?;
    Ok(())
}

Tip

Open the topic subscription before you start monitoring pins. That way the host already has a buffer waiting when the first edge arrives.

Endpoint Catalog

The library exposes one typed async method per firmware capability.

MethodArgumentsPurpose
pingidEcho a u32 back from firmware
i2c_readaddress, countRead bytes from an I2C target
i2c_writeaddress, contentsWrite bytes to an I2C target
i2c_write_readaddress, contents, countWrite, then read with a repeated start
i2c_scaninclude_reservedScan the I2C bus for responding addresses
i2c_batchaddress, opsExecute one bus transaction: no STOP between adjacent same-direction operations, repeated START on direction changes, and one final STOP
i2c_set_configfrequencySet the I2C clock frequency
i2c_get_config—Read back the active I2C frequency
spi_readcountRead bytes from the SPI bus
spi_writecontentsWrite bytes to the SPI bus
spi_transfercontentsFull-duplex SPI transfer
spi_flush—Flush pending SPI traffic
spi_batchcs_pin, opsExecute atomic multi-step SPI traffic under chip-select (see below)
spi_set_configspi_frequency, spi_phase, spi_polaritySet SPI timing and mode
spi_get_config—Read back the active SPI configuration
uart_readcount, timeout_msRead up to count bytes; 0 performs a single non-blocking poll and returns whatever is already buffered (possibly nothing), while oversized non-zero timeouts clamp at 30 minutes
uart_writecontentsQueue bytes for UART transmit
uart_flush—Wait until UART TX has drained
uart_set_configbaud_rate, data_bits, parity, stop_bitsReplace the complete UART configuration
uart_get_config—Return UartConfigurationInfo with the last requested baud rate, data bits, parity, and stop bits
gpio_getpinRead a GPIO level
gpio_putpin, stateDrive a GPIO high or low
gpio_wait_for_highpinWait until a pin reads high, bounded by the 30-minute firmware ceiling
gpio_wait_for_lowpinWait until a pin reads low, bounded by the 30-minute firmware ceiling
gpio_wait_for_rising_edgepinWait for a rising edge, bounded by the 30-minute firmware ceiling
gpio_wait_for_falling_edgepinWait for a falling edge, bounded by the 30-minute firmware ceiling
gpio_wait_for_any_edgepinWait for either edge, bounded by the 30-minute firmware ceiling
gpio_wait_for_*_with_timeoutpin, timeoutUse a shorter GPIO wait; zero and oversized values select the 30-minute ceiling, and expiry returns GpioError::Timeout
gpio_set_configpin, direction, pullSet GPIO direction and pull resistor
gpio_subscribepin, edgeAsk firmware to monitor a pin for edge events
gpio_unsubscribepinStop firmware-side monitoring
version—Read the firmware version
device_info—Read firmware version, schema version, HW revision, capabilities, runtime GPIO count, and build identity
validate—Perform a strict schema compatibility check and return DeviceInfo
num_gpios—Read the device-reported GPIO count; validates lazily on the first call, then caches
pwm_set_duty_cyclechannel, dutySet a raw PWM duty-cycle value
pwm_get_duty_cyclechannelRead current and maximum PWM duty
pwm_enablechannelEnable the PWM slice behind a channel
pwm_disablechannelDisable the PWM slice behind a channel
pwm_set_configchannel, frequency_hz, phase_correctSet PWM frequency and phase-correct mode
pwm_get_configchannelRead the active PWM configuration
adc_readchannelRead one ADC sample
adc_get_config—Read ADC capabilities and constants
onewire_reset—Reset the 1-Wire bus and detect presence
onewire_readlenRead raw 1-Wire bytes
onewire_writedataWrite raw 1-Wire bytes
onewire_write_pullupdata, pullup_duration_msWrite, then hold the line high for parasitic-power devices
onewire_search—Start ROM search and return the first device
onewire_search_next—Continue the current ROM search

Note

The library refuses over-ceiling payloads locally, before transmitting. Data returned by the device is limited to MAX_RESPONSE_PAYLOAD (1014 bytes); data sent to it is limited to MAX_TRANSFER_SIZE (4096 bytes). Full-duplex spi_transfer is limited to 1014 bytes because the same length must fit in both directions. i2c_batch and spi_batch are bounded a third way: their aggregate outgoing bytes must fit one MAX_REQUEST_FRAME (5119-byte) request frame, which i2c_batch_request_frame_len and spi_batch_request_frame_len compute. A refusal is PicoDeGalloError::Endpoint(...::BufferTooLong); see troubleshooting.

uart_set_config takes UartDataBits, UartParity, and UartStopBits alongside the baud rate. It has no partial-update form: read uart_get_config() and pass the framing fields back when changing only the baud rate. The firmware applies the divisor before the framing and drains neither direction, so serialize UART access and quiesce both directions across reconfiguration.

#![allow(unused)]
fn main() {
use pico_de_gallo_lib::{PicoDeGallo, UartDataBits, UartParity, UartStopBits};

async fn configure(gallo: &PicoDeGallo) -> Result<(), Box<dyn std::error::Error>> {
gallo
    .uart_set_config(
        115_200,
        UartDataBits::Eight,
        UartParity::None,
        UartStopBits::One,
    )
    .await?;

let active = gallo.uart_get_config().await?;
println!(
    "{} {:?} {:?} {:?}",
    active.baud_rate, active.data_bits, active.parity, active.stop_bits,
);
Ok(())
}
}

For the full API surface, field docs, and current signatures, use the crate reference on docs.rs.

pico-de-gallo-hal

pico-de-gallo-hal lets you run real embedded-hal driver code against a Pico de Gallo board on your laptop.

That is the whole value proposition:

  • write your driver against standard traits,
  • swap in pico-de-gallo-hal during host-side testing,
  • iterate without cross-compiling, flashing, linker scripts, or probe tools.

If your driver already speaks embedded-hal, this crate turns Pico de Gallo into a host-side transport layer instead of a custom test harness.

Runtime Model

Hal::new() works in both sync and async host code.

  • Inside a Tokio runtime, the crate uses tokio::task::block_in_place() for blocking trait calls.
  • Outside Tokio, it creates and owns its own runtime.

That means one Hal value can back ordinary tests, examples, and async host applications.

Construction

MethodPurpose
Hal::new()Connect to the first matching board
Hal::new_with_serial_number(serial)Connect to one specific board

Accessors and Helpers

The current public API is:

MethodReturnsPurpose
i2c()I2cI2C bus handle implementing blocking and async traits
spi()SpiRaw SPI bus handle
spi_device(cs_pin)Result<SpiDev, SpiHalError>SPI device handle that manages chip-select for you; validates cs_pin before driving it
num_gpios()Result<u8, HalInitError>Device-reported GPIO count — the runtime bound for a pin index or chip select
uart()UartUART handle implementing embedded_io and embedded_io_async (0.6 by default, additive 0.7 — see Feature Flags)
gpio(pin)GpioGPIO pin handle implementing digital traits
pwm_channel(channel)PwmChannelPWM channel handle implementing SetDutyCycle
delay()DelayDelay provider
onewire()OneWireProject-specific 1-Wire handle
adc_read(channel)Result<u16, AdcHalError>Single-shot ADC read
adc_get_config()Result<AdcConfigurationInfo, AdcHalError>ADC capabilities/configuration
i2c_set_config(frequency)Result<(), I2cHalError>Set I2C frequency
i2c_get_config()Result<I2cFrequency, I2cHalError>Read I2C frequency
spi_set_config(freq, phase, polarity)Result<(), SpiHalError>Set SPI mode and clock
spi_get_config()Result<SpiConfigurationInfo, SpiHalError>Read SPI configuration
pwm_set_config(channel, freq, phase_correct)Result<(), PwmHalError>Set PWM slice configuration
pwm_get_config(channel)Result<PwmConfigurationInfo, PwmHalError>Read PWM slice configuration
gpio_subscribe(pin, edge)Result<(), GpioHalError>Start firmware-side GPIO monitoring
gpio_unsubscribe(pin)Result<(), GpioHalError>Stop GPIO monitoring

Note

The source-of-truth API currently exposes gpio(pin) and uart(). There are not separate output_pin(), input_pin(), or uart_async() constructors; the returned handles implement the relevant blocking and async traits directly.

Implemented Traits

PeripheralBlocking traitAsync trait
GPIOOutputPin, InputPin, StatefulOutputPinWait
I2Cembedded_hal::i2c::I2cembedded_hal_async::i2c::I2c
SPISpiBus, SpiDeviceSpiBus, SpiDevice
UARTembedded_io::Read, embedded_io::Writeembedded_io_async::Read, embedded_io_async::Write
PWMSetDutyCycle—
DelayDelayNsDelayNs

The UART row is feature-gated by embedded-io major version — see Feature Flags below.

The Uart handle uses the baud rate and framing currently configured on the device; the HAL cannot change either. If a driver requires a particular UART configuration, apply it before using hal.uart() through gallo uart set-config, pico-de-gallo-lib, the C FFI, or Python. Do not reconfigure the same device concurrently with HAL reads or writes: firmware applies the baud divisor before the framing and drains neither direction.

And two project-specific surfaces sit alongside the trait-based ones:

Type / methodWhy it exists
OneWire via hal.onewire()there is no standard embedded-hal 1-Wire trait
adc_read() / adc_get_config()there is no stable embedded-hal ADC trait in 1.0

Feature Flags

embedded-io 0.6 and 0.7 are both supported through additive features:

FeatureDefaulthal.uart() implements
embedded-io-07noembedded-io 0.7 + embedded-io-async 0.7 traits
embedded-io-06yesembedded-io 0.6 + embedded-io-async 0.6 traits
# 0.6 only (default)
pico-de-gallo-hal = "0.7"

# 0.7 only
pico-de-gallo-hal = { version = "0.7", default-features = false, features = ["embedded-io-07"] }

# both majors at once
pico-de-gallo-hal = { version = "0.7", features = ["embedded-io-07"] }

Minimal Example

use embedded_hal::i2c::I2c;
use pico_de_gallo_hal::Hal;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let hal = Hal::new();
    let mut i2c = hal.i2c();

    let mut buf = [0u8; 2];
    i2c.write_read(0x48, &[0x00], &mut buf)?;

    println!("raw bytes: {:02x?}", buf);
    Ok(())
}

That same pattern is why this crate is so useful in driver development: the code above looks like ordinary embedded Rust because it is ordinary embedded Rust.

Transparent Transaction Batching

Two methods matter a lot for performance:

  • I2c::transaction()
  • SpiDevice::transaction()

The HAL does not turn those into several USB round-trips. Instead, it encodes the operations into Pico de Gallo batch requests and sends them in one shot.

So if your driver already uses the transaction APIs from embedded-hal, you get Pico de Gallo’s batching support automatically.

For I²C, the batch is also one transaction on the bus: adjacent operations of the same type run back to back without a STOP, changing direction emits a repeated START, and only the final operation is followed by a STOP. Two adjacent writes therefore form one gather write. This requires firmware built from schema 0.7 or newer; older firmware ran each operation as a separate transaction. The crate implements only I2c::transaction() directly, so the embedded-hal defaults for read, write, and write_read all use these same semantics.

#![allow(unused)]
fn main() {
use embedded_hal::i2c::{I2c, Operation};
use pico_de_gallo_hal::Hal;

fn read_register(hal: &Hal) -> Result<[u8; 2], Box<dyn std::error::Error>> {
    let mut i2c = hal.i2c();
    let mut buf = [0u8; 2];

    i2c.transaction(
        0x48,
        &mut [
            Operation::Write(&[0x00]),
            Operation::Read(&mut buf),
        ],
    )?;

    Ok(buf)
}
}

For SPI devices, spi_device(cs_pin) wraps the same idea with automatic CS assert/deassert around the whole transaction.

Chip-select bounds

spi_device(cs_pin) validates cs_pin against the GPIO count the connected board reports before it drives the pin, so a refused chip-select leaves the pin exactly as you configured it. Read the count yourself with num_gpios(); prefer it over the compile-time pico_de_gallo_lib::NUM_GPIOS, which is only this build’s default.

The first lookup performs one implicit validated device/info round-trip, bounded at 300 seconds; the value is then cached and shared by every handle cloned from the same connection. SpiHalError keeps the outcomes disjoint:

VariantMeaning
InvalidCsPin { cs, num_gpios }index at or beyond the reported count
NoGpiosthe board reports zero GPIOs
DeviceInfo(ValidateError)the count could not be established — transport, 300-second timeout, legacy firmware, or schema mismatch
Comms(String)the CS-high drive or the batch transport failed
Spi(SpiError)the firmware refused or failed the transfer

DeviceInfo(_) is never reported as an invalid chip-select: a failure to learn the valid range is a connectivity or compatibility problem, not an argument problem.

num_gpios() returns HalInitError::Validate(_) on failure, including ValidateError::Timeout. Failures are not cached, so retrying is fine.

When to Reach for This Crate

Use pico-de-gallo-hal when you want to:

  • validate a driver crate against real hardware behavior,
  • keep one code path for host-side tests and MCU targets,
  • avoid writing custom mocks before you know the driver is correct.

For a full walk-through, jump ahead to Testing with pico-de-gallo-hal in Part V.

pico-de-gallo-ffi

pico-de-gallo-ffi is the C-facing surface for Pico de Gallo. It wraps pico-de-gallo-lib behind an opaque pointer and a stable Status enum so C, C++, Zig, and other FFI-friendly languages can use the device without knowing anything about Rust internals.

At a glance:

  • the device handle is opaque: C code only sees const PicoDeGallo *,
  • the handle is safe to share across threads (Send + Sync on the Rust side),
  • all FFI calls drive the async Rust client on a shared multi-threaded Tokio runtime (required by the postcard-rpc nusb transport),
  • the crate builds as a cdylib:
    • Linux: libpico_de_gallo_ffi.so
    • macOS: libpico_de_gallo_ffi.dylib
    • Windows: pico_de_gallo_ffi.dll

Lifecycle

Every FFI program follows the same three-step shape:

  1. create a handle,
  2. call gallo_* functions,
  3. free the handle.
#include "pico_de_gallo.h"

const PicoDeGallo *gallo = gallo_init();
uint32_t id = 42;
Status s = gallo_ping(gallo, &id);
gallo_free(gallo);

Initialization and teardown

FunctionPurpose
const PicoDeGallo *gallo_init(void)Connect to the first matching board (lazy — failures surface on first RPC)
const PicoDeGallo *gallo_init_with_serial_number(const char *serial)Connect to a board with a specific USB serial number (lazy)
const PicoDeGallo *gallo_init_strict(void)Like gallo_init but calls validate() before returning; returns NULL on schema mismatch or device-not-present
const PicoDeGallo *gallo_init_strict_with_serial_number(const char *serial)Like the above with serial-number selection; recommended in production
void gallo_free(const PicoDeGallo *gallo)Release the opaque handle; NULL is a safe no-op

Status Codes

All operational functions return Status.

  • Status::Ok is success.
  • All failures are negative values.
  • The values are part of the stable C ABI.

Warning

Status values are append-only. Do not renumber existing codes, and do not overload an old value with a new meaning. Existing C callers may already have those integers compiled into switch statements.

The full status-code list lives in the Status Code Reference.

Limits and configuration enums

The header exports the firmware’s transfer limits and pin count, so C callers can size buffers and validate indices from the same numbers the firmware enforces instead of hard-coding copies:

#define GALLO_MAX_TRANSFER_SIZE 4096
#define GALLO_MAX_RESPONSE_PAYLOAD 1014
#define GALLO_MAX_REQUEST_FRAME 5119
#define GALLO_MAX_BATCH_OPS 64
#define GALLO_NUM_GPIOS 4

The direction of the bytes selects the limit. GALLO_MAX_TRANSFER_SIZE is the 4096-byte limit for data sent from the host to the device. GALLO_MAX_RESPONSE_PAYLOAD is the 1014-byte limit for data the device must return, including the aggregate returned by a batch. A full-duplex gallo_spi_transfer uses the response limit for its entire length because each byte travels in both directions. GALLO_MAX_REQUEST_FRAME is the 5119-byte limit on a whole request frame; it binds only gallo_i2c_batch and gallo_spi_batch, whose aggregate outgoing bytes nothing else bounds — an individual batch Write is deliberately not capped at GALLO_MAX_TRANSFER_SIZE. Exceeding any of the three yields Status::BufferTooLong locally, before transmission. See troubleshooting. Exceeding GALLO_MAX_BATCH_OPS in gallo_i2c_batch or gallo_spi_batch yields Status::InvalidArgument.

Note

gallo_spi_write accepts 4096 bytes, but gallo_spi_transfer accepts only 1014. Sizing both from GALLO_MAX_TRANSFER_SIZE is incorrect: the latter must also fit its returned bytes in GALLO_MAX_RESPONSE_PAYLOAD.

GALLO_NUM_GPIOS bounds the valid pin indices, 0..GALLO_NUM_GPIOS, which map to physical GPIO8-GPIO11 on the Pico 2 header. Anything at or above it yields Status::GpioInvalidPin. The header pin silkscreened SPI_CS (GPIO5) is a PCB net label, not a usable chip select: it is not one of these indices and no firmware claims or drives it. See issue #99.

For the SPI chip select in gallo_spi_batch, prefer the runtime count from gallo_num_gpios over this compile-time constant — see below. GALLO_NUM_GPIOS is only this build’s default, and the library checks cs_pin against what the connected board actually reports.

All three mirror constants in pico-de-gallo-internal, and a compile-time assertion in pico-de-gallo-ffi fails the build if they ever disagree.

The header also names the integer values that the gallo_* entry points already accept and return as uint8_t:

EnumValuesUsed by
GalloI2cFrequency_Standard 0, _Fast 1, _FastPlus 2gallo_i2c_set_config, gallo_i2c_get_config
GalloGpioDirection_Input 0, _Output 1gallo_gpio_set_config
GalloGpioPull_None 0, _Up 1, _Down 2gallo_gpio_set_config
GalloGpioEdge_Rising 0, _Falling 1, _Any 2gallo_gpio_subscribe
GalloUartDataBits_Five 0, _Six 1, _Seven 2, _Eight 3gallo_uart_set_config, gallo_uart_get_config
GalloUartParity_None 0, _Odd 1, _Even 2, _Mark 3, _Space 4gallo_uart_set_config, gallo_uart_get_config
GalloUartStopBits_One 0, _Two 1gallo_uart_set_config, gallo_uart_get_config
GalloI2cBatchOpTag_Read 0, _Write 1GalloI2cBatchOp::tag
GalloSpiBatchOpTag_Read 0, _Write 1, _Transfer 2, _DelayNs 3GalloSpiBatchOp::tag

Variant names are prefixed with the enum name (GalloGpioPull_Up), because C enum variants share a single global namespace and several of these would otherwise collide — both batch-op tags define Read and Write.

Note

These enums are additive. The function signatures still take plain uint8_t, so existing code that passes literals keeps working unchanged.

Warning

Like Status, these values are stable C ABI and must match the pico-de-gallo-internal wire enums they mirror. Wire-enum variant order is itself ABI, because postcard serializes by variant index. A unit test (config_enums_match_wire_enums) asserts the two numberings agree.

Function Reference

The generated header is the canonical API surface, but these are the functions you will use most often.

Ping and device metadata

Status gallo_ping(const PicoDeGallo *gallo, uint32_t *id);

Status gallo_version(const PicoDeGallo *gallo,
                     uint16_t *major, uint16_t *minor, uint32_t *patch);

Status gallo_get_device_info(const PicoDeGallo *gallo, GalloDeviceInfo *info);

Status gallo_num_gpios(const PicoDeGallo *gallo, uint8_t *out_num_gpios);

Status gallo_system_reset_subscriptions(const PicoDeGallo *gallo,
                                        uint8_t *out_reset);

gallo_get_device_info returns firmware version, schema version, hardware revision, a capability bitfield, and build_id, a NUL-terminated firmware identity in the final member of GalloDeviceInfo. The buffer size is GALLO_BUILD_ID_LEN (65 bytes: up to 64 bytes plus the terminator). The identity is informational and never affects the return status. Inspect *info, including its terminator, only after the function returns Ok; the output is not written on any error path.

gallo_num_gpios returns the GPIO count the connected device reports — the runtime-authoritative bound for a pin index and for the SPI chip select. It performs one device/info round-trip on first use, bounded at 300 seconds, and caches the value per handle; the round-trip also validates the firmware’s reported schema version. *out_num_gpios is written only on Ok, including a successful zero.

gallo_system_reset_subscriptions tears down any GPIO subscriptions left over from a previous host session and writes the reset count to *out_reset (which may be NULL if the caller does not need the count). Subscriptions are server-side state that outlives the USB transport, so a host that crashed without calling gallo_gpio_unsubscribe leaves the affected pins owned by firmware monitor tasks. Call this once on connect, immediately after gallo_init (or after validate() in the Rust library), to reclaim those pins. The call is idempotent and cheap on a fresh device.

I2C

Status gallo_i2c_read(const PicoDeGallo *gallo,
                      uint8_t address, uint8_t *buf, size_t len);
Status gallo_i2c_write(const PicoDeGallo *gallo,
                       uint8_t address, const uint8_t *buf, size_t len);
Status gallo_i2c_write_read(const PicoDeGallo *gallo,
                            uint8_t address,
                            const uint8_t *txbuf, size_t txlen,
                            uint8_t *rxbuf, size_t rxlen);
Status gallo_i2c_scan(const PicoDeGallo *gallo,
                      bool include_reserved,
                      uint8_t *buf, size_t buf_len, size_t *found);
Status gallo_i2c_set_config(const PicoDeGallo *gallo, uint8_t frequency);
Status gallo_i2c_get_config(const PicoDeGallo *gallo, uint8_t *out_frequency);

frequency uses the wire enum encoding: 0 = Standard, 1 = Fast, 2 = FastPlus. The header also defines GalloI2cFrequency (GalloI2cFrequency_Standard, _Fast, _FastPlus) if you would rather pass a name than a number — see Limits and configuration enums.

I2C batch

typedef struct GalloI2cBatchOp {
    uint8_t       tag;       // 0 = Read, 1 = Write
    uint16_t      read_len;  // Read variant
    const uint8_t *data;     // Write variant (must be non-NULL; data_len > 0)
    size_t        data_len;  // Write variant
} GalloI2cBatchOp;

Status gallo_i2c_batch(const PicoDeGallo *gallo,
                       uint8_t address,
                       const GalloI2cBatchOp *ops, size_t ops_count,
                       uint8_t *out_buf, size_t out_capacity,
                       size_t *out_len,
                       uint16_t *out_failed_op);  // may be NULL

The batch executes as a single I2C transaction. A START and address precede the first operation; adjacent operations of the same type are sent back to back with no STOP and no repeated START between them, so two adjacent Write ops form one gather write; a direction change emits a repeated START and re-addresses the target; and only the last operation is followed by a STOP.

A Write op must carry at least one byte. gallo_i2c_batch rejects data_len == 0 with InvalidArgument before contacting the device, writing the offending operation’s index to *out_failed_op. Likewise gallo_i2c_write rejects len == 0. This hardware cannot emit an address-only transaction — see Zero-length writes.

This requires firmware built from schema 0.7 or newer. Older firmware executes each operation as a separate transaction. Zero-length writes are rejected.

Concatenated read data is written to out_buf and the total length to *out_len. On failure, *out_failed_op (if non-NULL) receives the zero-based index of the operation that failed, and the status reflects the underlying I2C error (I2cNack, I2cBusError, etc.). BufferTooLong means either that the aggregate returned data exceeds GALLO_MAX_RESPONSE_PAYLOAD or that out_buf was too small. In the latter case, *out_len still receives the required capacity.

SPI

Status gallo_spi_read(const PicoDeGallo *gallo, uint8_t *buf, size_t len);
Status gallo_spi_write(const PicoDeGallo *gallo, const uint8_t *buf, size_t len);
Status gallo_spi_flush(const PicoDeGallo *gallo);
Status gallo_spi_set_config(const PicoDeGallo *gallo,
                            uint32_t frequency,
                            bool spi_phase, bool spi_polarity);
Status gallo_spi_get_config(const PicoDeGallo *gallo,
                            uint32_t *out_frequency,
                            bool *out_phase, bool *out_polarity);

SPI full-duplex transfer

Status gallo_spi_transfer(const PicoDeGallo *gallo,
                          const uint8_t *write_buf,
                          uint8_t       *read_buf,
                          size_t         len);

Simultaneously sends len bytes from write_buf on MOSI and receives len bytes on MISO into read_buf. The two buffers may alias. Returns BufferTooLong locally if len exceeds GALLO_MAX_RESPONSE_PAYLOAD, or SpiTransferFailed on a generic SPI error.

SPI batch

typedef struct GalloSpiBatchOp {
    uint8_t       tag;       // 0 = Read, 1 = Write, 2 = Transfer, 3 = DelayNs
    uint16_t      read_len;  // Read variant
    const uint8_t *data;     // Write/Transfer variant (may be NULL when data_len == 0)
    size_t        data_len;  // Write/Transfer variant
    uint32_t      delay_ns;  // DelayNs variant
} GalloSpiBatchOp;

Status gallo_spi_batch(const PicoDeGallo *gallo,
                       uint8_t cs_pin,
                       const GalloSpiBatchOp *ops, size_t ops_count,
                       uint8_t *out_buf, size_t out_capacity,
                       size_t *out_len,
                       uint16_t *out_failed_op);  // may be NULL

The firmware asserts cs_pin low before the first operation and deasserts it after the last (or on error), providing atomic SpiDevice::transaction semantics. Read data from Read and Transfer operations is concatenated into out_buf in order. On per-op failure, *out_failed_op (if non-NULL) receives the zero-based index. BufferTooLong means either that the aggregate returned data exceeds GALLO_MAX_RESPONSE_PAYLOAD or that out_buf was too small. In the latter case, *out_len still receives the required capacity.

Chip-select preflight

cs_pin is validated against the device-reported GPIO count before any operation payload is translated and before anything is transmitted:

uint8_t n = 0;
Status s = gallo_num_gpios(gallo, &n);   // writes n only on Ok

gallo_num_gpios performs one device/info round-trip on first use — which also validates the firmware’s reported schema version — and caches the answer per handle. A device reporting zero is a success and writes zero; every error leaves your buffer untouched, so a sentinel written before the call is a reliable “was this populated?” check.

gallo_spi_batch returns:

StatusMeaning
SpiInvalidCsPin (−71)cs_pin is at or beyond the reported count
SpiNoGpios (−74)the device reports zero GPIOs
DeviceInfoFailed (−62)the count could not be read (transport/decode)
DeviceInfoTimeout (−75)device/info did not answer within 300 seconds
LegacyFirmware (−64)the firmware has no device/info endpoint
SchemaMismatch (−63)host and firmware disagree on the wire version

The last four mean the host could not establish the valid range at all; they are never reported as an invalid chip-select. A refused chip-select drives no pin, sends nothing, leaves *out_len at zero, and never writes *out_failed_op — only a firmware-side per-operation failure does that.

Ordering inside gallo_spi_batch is fixed: validate gallo, validate out_len, validate the top-level ops/ops_count/out_capacity shape, write *out_len = 0, resolve the GPIO count, classify cs_pin, translate the operation payloads, call the library once, then write outputs. A NULL out_len is therefore never dereferenced, and an invalid op tag or NULL data pointer is reported only after the device has been reached.

GPIO

Status gallo_gpio_get(const PicoDeGallo *gallo, uint8_t pin, bool *state);
Status gallo_gpio_put(const PicoDeGallo *gallo, uint8_t pin, bool state);
Status gallo_gpio_wait_for_high(const PicoDeGallo *gallo, uint8_t pin);
Status gallo_gpio_wait_for_low(const PicoDeGallo *gallo, uint8_t pin);
Status gallo_gpio_wait_for_rising_edge(const PicoDeGallo *gallo, uint8_t pin);
Status gallo_gpio_wait_for_falling_edge(const PicoDeGallo *gallo, uint8_t pin);
Status gallo_gpio_wait_for_any_edge(const PicoDeGallo *gallo, uint8_t pin);
Status gallo_gpio_set_config(const PicoDeGallo *gallo,
                             uint8_t pin, uint8_t direction, uint8_t pull);
Status gallo_gpio_subscribe(const PicoDeGallo *gallo, uint8_t pin, uint8_t edge);
Status gallo_gpio_unsubscribe(const PicoDeGallo *gallo, uint8_t pin);

The five wait functions above send timeout_ms == 0, which selects the firmware’s 30-minute ceiling rather than waiting forever. Their _with_timeout_ms variants accept an explicit timeout; 0 selects the same ceiling, oversized values are clamped to it, and expiry returns GpioTimeout (−70).

UART

Status gallo_uart_read(const PicoDeGallo *gallo,
                       uint8_t *buf, uint16_t count,
                       uint32_t timeout_ms, uint16_t *out_len);
Status gallo_uart_write(const PicoDeGallo *gallo,
                         const uint8_t *buf, uint16_t len);
Status gallo_uart_flush(const PicoDeGallo *gallo);
Status gallo_uart_set_config(const PicoDeGallo *gallo,
                             uint32_t baud_rate,
                             uint8_t data_bits,
                             uint8_t parity,
                             uint8_t stop_bits);
Status gallo_uart_get_config(const PicoDeGallo *gallo,
                             uint32_t *out_baud_rate,
                             uint8_t *out_data_bits,
                             uint8_t *out_parity,
                             uint8_t *out_stop_bits);

For gallo_uart_read, timeout_ms == 0 is a deliberate exception: it performs a single non-blocking poll and returns whatever is already buffered (possibly nothing). Non-zero values above the firmware’s 30-minute ceiling are clamped to it; expiry returns success with *out_len == 0.

The three framing arguments and outputs use GalloUartDataBits, GalloUartParity, and GalloUartStopBits; values outside those enums return InvalidArgument. The C API has no defaults: gallo_uart_set_config replaces the complete configuration, so a baud-only change must first read and then repeat the active framing. The firmware applies the divisor before the framing and drains neither direction, making the transition non-atomic; quiesce UART traffic across the call.

PWM

Status gallo_pwm_set_duty_cycle(const PicoDeGallo *gallo,
                                uint8_t channel, uint16_t duty);
Status gallo_pwm_get_duty_cycle(const PicoDeGallo *gallo,
                                uint8_t channel,
                                uint16_t *out_duty, uint16_t *out_max_duty);
Status gallo_pwm_enable(const PicoDeGallo *gallo, uint8_t channel);
Status gallo_pwm_disable(const PicoDeGallo *gallo, uint8_t channel);
Status gallo_pwm_set_config(const PicoDeGallo *gallo,
                            uint8_t channel,
                            uint32_t frequency_hz, bool phase_correct);
Status gallo_pwm_get_config(const PicoDeGallo *gallo,
                            uint8_t channel,
                            uint32_t *out_frequency_hz,
                            bool *out_phase_correct, bool *out_enabled);

ADC

Status gallo_adc_read(const PicoDeGallo *gallo,
                      uint8_t channel, uint16_t *out_value);
Status gallo_adc_get_config(const PicoDeGallo *gallo,
                            uint8_t *out_resolution_bits,
                            uint16_t *out_nominal_reference_mv,
                            uint8_t *out_num_gpio_channels);

1-Wire

Status gallo_onewire_reset(const PicoDeGallo *gallo, bool *out_present);
Status gallo_onewire_read(const PicoDeGallo *gallo,
                          uint8_t *buf, uint16_t len, uint16_t *out_len);
Status gallo_onewire_write(const PicoDeGallo *gallo,
                           const uint8_t *buf, uint16_t len);
Status gallo_onewire_write_pullup(const PicoDeGallo *gallo,
                                  const uint8_t *buf, uint16_t len,
                                  uint16_t pullup_duration_ms);
Status gallo_onewire_search(const PicoDeGallo *gallo,
                            uint64_t *out_rom_ids, uint16_t max_count,
                            uint16_t *out_count);

Building and Linking

Build the shared library

cd crates/pico-de-gallo-ffi
cargo build --release

Outputs:

PlatformArtifact
Linuxtarget/release/libpico_de_gallo_ffi.so
macOStarget/release/libpico_de_gallo_ffi.dylib
Windowstarget/release/pico_de_gallo_ffi.dll and pico_de_gallo_ffi.dll.lib

Static library

The crate also builds a staticlib, alongside the shared library rather than instead of it:

PlatformArtifact
Linux, macOStarget/release/libpico_de_gallo_ffi.a
Windowstarget/release/pico_de_gallo_ffi.lib

Link it when you want a self-contained executable and no runtime search-path setup — the Zephyr module does exactly this so the native_sim runner carries the FFI inside zephyr.exe and needs no -Wl,-rpath. Expect a substantially larger binary, since the whole async runtime and USB stack come along.

A Rust staticlib does not carry its transitive system-library requirements, so a manual link needs them spelled out. Ask the toolchain rather than guessing:

cargo rustc --release --crate-type staticlib -- --print native-static-libs

On x86-64 Linux that currently reports:

-lgcc_s -lutil -lrt -lpthread -lm -ldl -lc

The list is platform-specific; re-run it on macOS or Windows instead of reusing the Linux one. Only the shared library is published as a release asset — see the release workflow for the exact asset names.

Generated header

The header is generated by cbindgen during the build. Look under Cargo’s OUT_DIR for pico_de_gallo.h:

target/release/build/pico-de-gallo-ffi-<hash>/out/include/pico_de_gallo.h

Note

Do not hand-edit the header. It is generated from the Rust definitions and is supposed to stay in lockstep with them.

cbindgen notes

cbindgen.toml in the crate root controls generation. The important bits are:

  • language: C,
  • include guard: PICO_DE_GALLO_H,
  • style: both tagged and typedef forms,
  • line endings: LF,
  • an [export] include list.

Two cbindgen behaviours are worth knowing before you add anything to the header, because both fail silently:

  • Unreferenced types are pruned. cbindgen only emits types reachable from an exported function signature. The configuration enums above appear in no signature, so they must be listed in [export] include or they vanish from the header with no warning.
  • Const initializers must be literals. cbindgen folds them syntactically, so pub const A: usize = some::path::B; compiles cleanly and emits nothing. Write the literal and guard it with a const assertion instead — that is what GALLO_MAX_TRANSFER_SIZE, GALLO_MAX_BATCH_OPS and GALLO_NUM_GPIOS do.

Complete Example

#include <stdint.h>
#include <stdio.h>
#include "pico_de_gallo.h"

int main(void) {
    const PicoDeGallo *gallo = gallo_init();
    if (!gallo) {
        fprintf(stderr, "Failed to connect to device\n");
        return 1;
    }

    uint32_t id = 0xDEADBEEF;
    Status s = gallo_ping(gallo, &id);
    if (s != Ok) {
        fprintf(stderr, "Ping failed: %d\n", s);
        gallo_free(gallo);
        return 1;
    }
    printf("Ping OK, got back: 0x%08X\n", id);

    uint16_t major, minor;
    uint32_t patch;
    s = gallo_version(gallo, &major, &minor, &patch);
    if (s == Ok) {
        printf("Firmware v%u.%u.%u\n", major, minor, patch);
    }

    GalloDeviceInfo info;
    s = gallo_get_device_info(gallo, &info);
    if (s == Ok) {
        printf("Schema v%u.%u.%u, HW rev %u\n",
               info.schema_major, info.schema_minor,
               info.schema_patch, info.hw_version);
    } else if (s == SchemaMismatch) {
        fprintf(stderr, "Schema mismatch — update firmware or host library\n");
    }

    uint8_t buf[2] = {0};
    s = gallo_i2c_read(gallo, 0x50, buf, sizeof(buf));
    if (s != Ok) {
        fprintf(stderr, "I2C read failed: %d\n", s);
        gallo_free(gallo);
        return 1;
    }

    printf("Read: 0x%02X 0x%02X\n", buf[0], buf[1]);
    gallo_free(gallo);
    return 0;
}

pyco-de-gallo

pyco-de-gallo exposes Pico de Gallo to Python as the pyco_de_gallo module. It is built with PyO3 + maturin, and its API is intentionally boring in the best way: open a device, call methods, get Python values back.

The key design point is that the Python surface is synchronous. Each PycoDeGallo instance owns an internal Tokio runtime and drives the underlying async Rust client for you.

That means you get Python-friendly code without giving up the Rust transport layer underneath.

Installation

pyproject.toml declares requires-python = ">=3.8".

From PyPI

When wheels are published, install it like any other Python package:

$ pip install pyco-de-gallo

From source with maturin

$ cd crates/pyco-de-gallo
$ python -m pip install maturin
$ maturin develop --release

If you want a wheel instead of an editable/development install:

$ cd crates/pyco-de-gallo
$ maturin build --release

Opening a Device

At module level you get five entry points:

  • list_devices()
  • open() — lazy; failures surface on the first RPC
  • open_with_serial_number(serial_number) — lazy, selects by serial
  • open_strict() — validates the firmware’s schema version before returning; raises RuntimeError on mismatch or device-not-found
  • open_strict_with_serial_number(serial_number) — strict variant with serial selection (recommended for production)
import pyco_de_gallo as gallo

for dev in gallo.list_devices():
    print(dev.serial_number, dev.manufacturer, dev.product)

pg = gallo.open()
# or:
# pg = gallo.open_with_serial_number("E6633861A34B8C24")

The returned object is PycoDeGallo.

The PycoDeGallo Class

PycoDeGallo mirrors the Rust library closely.

  • methods are synchronous from Python,
  • Rust async work runs on an internal runtime,
  • the GIL is released while the binding waits on USB I/O,
  • most transport and endpoint failures become Python RuntimeError.

That gives you a straightforward, script-friendly surface:

import pyco_de_gallo as gallo

pg = gallo.open()
print(pg.ping(123))
print(pg.version().major, pg.version().minor, pg.version().patch)
print(pg.device_info().hw_version)
print(pg.device_info().num_gpios)
print(pg.device_info().build_id)

DeviceInfo.build_id is the firmware’s build-time git describe identity, or "unknown" when git was unavailable. A trailing -dirty identifies a build from a modified working tree. It is informational only and never controls whether validation or another call succeeds; record it when reproducing a result from a particular firmware image.

GPIO count and the SPI chip select

DeviceInfo.num_gpios and PycoDeGallo.num_gpios() both report the GPIO count the connected board advertises. That is the runtime-authoritative bound for a pin index and for spi_batch’s cs_pin; do not hardcode 4.

n = pg.num_gpios()          # one implicit device/info round-trip, then cached
data = pg.spi_batch(0, ops) # cs_pin checked against n before anything is sent

spi_batch resolves the count and classifies cs_pin before it converts the operation objects, so a refused chip-select costs nothing and drives no pin. All failures raise RuntimeError, with disjoint messages:

invalid SPI chip-select pin 7; device reports 4 GPIOs (valid 0..4)
device reports num_gpios=0; no SPI chip-select pin is available
failed to determine num_gpios: device/info did not respond within 300 seconds ...

Messages beginning failed to determine num_gpios mean the host could not establish the valid range at all — transport failure, the 300-second device/info timeout, legacy firmware, or a schema mismatch. They are never phrased as a chip-select complaint. A failed lookup is not cached, so retrying is allowed.

A cs_pin outside 0..=255 raises OverflowError during PyO3 argument extraction, before the device is contacted.

Enums and Value Types

The public Python names intentionally do not carry a Py prefix. You use plain Python-facing names like:

  • I2cFrequency
  • SpiPhase
  • SpiPolarity
  • GpioDirection
  • GpioPull
  • GpioEdge
  • UartDataBits
  • UartParity
  • UartStopBits
  • VersionInfo
  • DeviceInfo
  • UartConfigurationInfo
  • SpiConfigurationInfo
  • PwmDutyCycleInfo
  • PwmConfigurationInfo
  • AdcConfigurationInfo

Example:

import pyco_de_gallo as gallo

pg = gallo.open()
pg.i2c_set_config(gallo.I2cFrequency.Fast)
pg.spi_set_config(
    1_000_000,
    gallo.SpiPhase.CaptureOnFirstTransition,
    gallo.SpiPolarity.IdleLow,
)

UART parity has one Python-specific spelling: use UartParity.NoParity, not UartParity.None. The latter is a Python SyntaxError because None is a keyword. Only the binding name changes—the member is never renumbered and still maps to wire index 0; the wire enum, FFI enum, and generated C header all retain the name None.

uart_set_config(baud_rate, data_bits, parity, stop_bits) defaults its framing arguments to 8N1, but it replaces the complete configuration rather than performing a partial update. A baud-only change must repeat the current framing. Baud and framing are applied together but not atomically: the divisor changes first and neither direction is drained, so pause UART traffic across the call.

pg.uart_set_config(
    115_200,
    gallo.UartDataBits.Eight,
    gallo.UartParity.NoParity,
    gallo.UartStopBits.One,
)
active = pg.uart_get_config()
print(active.baud_rate, active.data_bits, active.parity, active.stop_bits)

Example: I2C Register Read

Note

Python refuses over-ceiling payloads locally, before transmitting. Data returned by the device is limited to MAX_RESPONSE_PAYLOAD (1014 bytes), while data sent to it is limited to MAX_TRANSFER_SIZE (4096 bytes). Full-duplex spi_transfer is therefore limited to 1014 bytes even though spi_write accepts 4096. i2c_batch and spi_batch are bounded a third way: their aggregate outgoing bytes must fit one MAX_REQUEST_FRAME (5119-byte) request frame. An over-ceiling call raises RuntimeError with the BufferTooLong message; see troubleshooting.

import pyco_de_gallo as gallo

pg = gallo.open()
pg.i2c_set_config(gallo.I2cFrequency.Fast)

data = pg.i2c_write_read(0x48, [0x00], 2)
raw = int.from_bytes(data, byteorder="big")
print(f"raw=0x{raw:04x}")
import time
import pyco_de_gallo as gallo

pg = gallo.open()
pg.gpio_set_config(0, gallo.GpioDirection.Output, gallo.GpioPull.Disabled)

for _ in range(10):
    pg.gpio_put(0, True)
    time.sleep(0.1)
    pg.gpio_put(0, False)
    time.sleep(0.1)

Example: ADC Read

import pyco_de_gallo as gallo

pg = gallo.open()
raw = pg.adc_read(0)
config = pg.adc_get_config()
voltage_mv = raw * config.nominal_reference_mv / 4095

print(f"ADC0 raw={raw} ~{voltage_mv:.1f} mV")

GPIO Event Subscriptions

GPIO push events are exposed through subscribe_gpio_events() and gpio_subscribe().

import pyco_de_gallo as gallo

pg = gallo.open()
sub = pg.subscribe_gpio_events(depth=16)
pg.gpio_subscribe(0, gallo.GpioEdge.Any)

event = sub.poll(timeout=1.0)
if event is not None:
    print(event.pin, event.edge, event.state)

pg.gpio_unsubscribe(0)
sub.close()

The subscription object also supports iteration and context-manager cleanup.

Error Handling

Rust-side errors are converted to RuntimeError.

import pyco_de_gallo as gallo

pg = gallo.open()

try:
    pg.uart_set_config(
        0,
        gallo.UartDataBits.Eight,
        gallo.UartParity.NoParity,
        gallo.UartStopBits.One,
    )
except RuntimeError as exc:
    print(f"operation failed: {exc}")

That includes transport failures, schema-validation failures, and peripheral errors reported by the firmware.

When to Use Python

Reach for pyco-de-gallo when you want quick experiments, production-test glue, lab automation, or notebook-style investigation without writing a Rust binary.

If you outgrow the synchronous Python surface, the next layer down is pico-de-gallo-lib, which exposes the full async Rust API.

gallo-mcp — MCP server

gallo-mcp bridges a Pico de Gallo to AI coding agents. It runs a Model Context Protocol server over stdio, wrapping pico-de-gallo-lib and exposing one tool per peripheral operation across I2C, SPI, UART, GPIO, PWM, ADC, and 1-Wire.

The point is the on-your-laptop development loop: while an agent writes an embedded driver, it can probe and drive real hardware through the same board gallo talks to — reading a sensor register, scanning a bus, toggling a pin — without cross-compiling or flashing anything.

Use it for:

  • letting an AI agent explore an unfamiliar device interactively,
  • generating and validating register-level driver code against real silicon,
  • turning “what does this chip return?” into a tool call the agent can make itself.

Install

$ cargo install gallo-mcp

Run

$ gallo-mcp [--serial-number <SN>]

gallo-mcp speaks the MCP protocol on stdout and logs to stderr, so it is meant to be launched by an MCP client rather than run interactively.

  • -s, --serial-number <SN> pins the server to one board. A pinned server cannot address any other board: a tool call naming a different serial is refused. This is the way to scope an agent session to a single board. If the pinned serial is not among the attached boards at startup, the server logs a warning naming the serials it did find, and starts anyway — see below.
  • Per-call connection: the server holds no persistent USB claim. Each tool call opens the board, runs, and releases it when the call completes, so the device is free for the gallo CLI or other host processes between calls. Each call re-opens and re-validates the board, so there is a small fixed per-call connection cost. The server starts even with no board attached and tools begin working as soon as a Pico de Gallo is present; you can plug the board in mid-session.

The pin is checked once at startup, and an unusable one is a warning rather than a startup failure — starting with no board attached has to keep working, so the server cannot treat “pin not attached” as fatal:

WARN gallo_mcp: This server is pinned to serial number 'BOGUSSERIAL' (--serial-number), which is not attached.
Available: 5256657D8A5D7F03

Nothing is logged when the pin resolves, when no pin was given, or when no board is attached at all — that last case is indistinguishable from “not plugged in yet”, which is supported. So the warning firing means a board is attached and the pin does not match it, which in practice means a typo. Without it a mistyped pin starts a server that looks healthy and then fails every device call for the rest of the session.

This warning is on by default — it has to be, because MCP clients launch the server with whatever environment they have and rarely set RUST_LOG. Logs go to stderr, so they never disturb the JSON-RPC stream on stdout. RUST_LOG still controls verbosity when you set it, and overrides the default entirely: RUST_LOG=error silences the warning, RUST_LOG=gallo_mcp=debug adds the per-call board-lock tracing.

Protocol revisions

gallo-mcp supports every MCP revision its SDK knows — 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and 2026-07-28 — and negotiates during initialize: it echoes the revision the client asks for when it is one of those, and otherwise falls back to 2025-11-25. The initialize result identifies the server as gallo-mcp with this crate’s version.

The tool surface does not vary by revision. The same 43 tools, with the same names, arguments, annotations, and JSON payloads, are served to every client. What changes is the envelope around them:

2025-11-25 and older2026-07-28
resultType on resultsabsent"complete"
ttlMs / cacheScope cache hintsabsentpresent
server/discovernot availableanswered

A client that negotiates 2026-07-28 may therefore start with a stateless server/discover instead of initialize, and will see the SEP-2322 result discriminator and SEP-2549 cache hints on every result. Everything else — tool payloads, the serial_number echo, error mapping — is byte-for-byte identical across revisions.

Choosing a board

Every tool except list_devices takes an optional serial_number, and every response that came from a board names the board it came from:

// i2c_scan {"serial_number":"5256657D8A5D7F03"}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "addresses": ["0x48"], "raw": [72] }
}

That { "serial_number", "result" } envelope wraps every device tool response but two. list_devices opens no board at all, and status reports its serial as a top-level field of its own result rather than nesting a serial under a serial.

The envelope’s serial_number is null in exactly one case: the sole attached board reports no USB serial number. The field is always present, so null there means “a board that has no serial answered” — never “this response was not enveloped”. status’s available list carries null entries for the same reason.

How the target is chosen:

Boards attachedserial_numberResult
0—error: no device attached
1omittedthat board
1giventhat board, if it matches
≥2omittederror, listing the available serials
≥2giventhe named board, if exactly one matches

With one board attached nothing changes — omit serial_number and it just works. With two or more, omitting it is an error rather than a guess:

Multiple Pico de Gallo devices attached; `serial_number` is required.
Available: 5256657D8A5D7F03, 568E9AAEC72B0D49

That is deliberate. Guessing turns a recoverable mistake into a confident wrong answer with no signal that anything went wrong; the error names the serials, so the next call succeeds.

Two boards can also report the same serial, which is refused for the same reason — naming it no longer identifies a board:

2 attached Pico de Gallo devices report serial number '0000000000000000'; they cannot be told apart. Detach all but one and retry.

That is reachable rather than hypothetical. The firmware derives the USB serial from the RP2350 chip ID and falls back to all-zeros when the OTP read fails, which its own comment notes happens on some dev boards, so two such boards collide. No argument fixes it; detach one.

A server started with -s is pinned, and the two ≥2 rows above no longer apply: serial_number is optional again however many boards are attached. An omitted argument uses the pinned board, a matching one is accepted, and a different one is refused:

This server is pinned to serial number '5256657D8A5D7F03' (--serial-number); it cannot address '568E9AAEC72B0D49'. Omit serial_number, or pass '5256657D8A5D7F03'.

Device state is per board — bus configuration, GPIO direction, PWM enable, and 1-Wire search progress all live on the board you addressed — so a follow-up call must repeat the serial_number of the call that set it up.

list_devices tells you which case you are in without connecting:

// list_devices {}
{
  "devices": [
    { "serial_number": "5256657D8A5D7F03", "manufacturer": null,
      "product": "Pico de Gallo", "pinned": false, "default_target": false },
    { "serial_number": "568E9AAEC72B0D49", "manufacturer": null,
      "product": "Pico de Gallo", "pinned": false, "default_target": false }
  ],
  "pinned": null,
  "serial_number_required": true,
  "note": "2 devices attached and this server is not pinned; pass serial_number on every device tool call."
}

note is present only when serial_number_required is true.

status never errors, so it stays answerable even when the target is ambiguous:

// status {}
{
  "attached": true,
  "serial_number": null,
  "ambiguous": true,
  "available": ["5256657D8A5D7F03", "568E9AAEC72B0D49"],
  "pinned": null,
  "reason": "Multiple Pico de Gallo devices attached; `serial_number` is required.\nAvailable: 5256657D8A5D7F03, 568E9AAEC72B0D49",
  "firmware_version": null,
  "schema_major": null,
  "schema_minor": null,
  "build_id": null
}

ambiguous answers “would a call that omits serial_number be ambiguous?” — not “would it fail?”. A bare call also fails with no board attached, and when the server is pinned to a board that is not attached; ambiguous is false in both. It stays true even when this particular status call named a board. reason is present only when no board was reached.

When status reaches a board, build_id contains that firmware image’s git describe identity; the key is always present and remains null before a connection succeeds. device_info returns the same identity. Each successful connection also logs the serial number, firmware version, and build identity at info level on stderr. The default filter enables that event; set RUST_LOG to override it.

Concurrency

The connection lock is keyed on the board, not on the server. Calls to different boards run concurrently; calls to the same board queue. A long-running tool — a gpio_wait_* sitting on its full timeout_ms — holds only the board it addressed, so traffic to every other board keeps flowing. Firmware clamps that timeout to a maximum of 30 minutes.

list_devices connects to nothing and always answers immediately. status does open the board it names, so it queues behind a call that is still holding that board.

Using it with an MCP client

Add gallo-mcp as a local (stdio) server in your client’s config. These files are safe to commit per-project, so the tools appear only in repos that opt in.

opencode (opencode.json)

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "pico-de-gallo": { "type": "local", "command": ["gallo-mcp"], "enabled": true }
  }
}

Claude Code (.mcp.json)

{ "mcpServers": { "pico-de-gallo": { "command": "gallo-mcp", "args": [] } } }

Cursor (.cursor/mcp.json)

{ "mcpServers": { "pico-de-gallo": { "command": "gallo-mcp", "args": [] } } }

Byte conventions

Byte payloads go in as hex strings and come back as both hex and a decimal array. Input accepts comma-separated or bare hex ("0x00,0x10" or "0010"); reads return { "hex": ..., "bytes": ... } inside the response envelope.

// i2c_write_read {"address":72,"data":"0x00","count":2}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "hex": "0x0B,0x8E", "bytes": [11, 142] }
}

Tool catalog

43 tools, grouped by peripheral. Read-only tools carry the readOnlyHint annotation; write/actuation tools carry destructiveHint. Every tool except list_devices accepts an optional serial_number.

device (read-only)

ToolDescription
list_devicesList connected Pico de Gallo boards
statusWhich board is reachable, why not when none is, plus firmware/schema/build identity
device_infoFirmware version, schema version, capabilities, runtime GPIO count, build identity
versionFirmware version
pingEcho a value (liveness check)

i2c

ToolDescriptionKind
i2c_readRead bytes from a target addressread-only
i2c_write_readWrite then read without releasing the busread-only
i2c_scanProbe the bus for responding addressesread-only
i2c_get_configShow the active I2C frequencyread-only
i2c_writeWrite bytes to a target addressdestructive
i2c_set_configSet the I2C frequencydestructive
i2c_batchAtomic multi-step transaction under one I2C addressdestructive

spi

ToolDescriptionKind
spi_readClock in bytesread-only
spi_transferFull-duplex transferread-only
spi_get_configShow the active SPI configurationread-only
spi_writeClock out bytesdestructive
spi_flushFlush the SPI bufferdestructive
spi_set_configSet frequency, phase, and polaritydestructive
spi_batchAtomic multi-step transaction under chip-selectdestructive

Note

MCP tools refuse over-ceiling payloads locally, before connecting to a board. Data returned by the device is limited to MAX_RESPONSE_PAYLOAD (1014 bytes), while data sent to it is limited to MAX_TRANSFER_SIZE (4096 bytes). Full-duplex spi_transfer is therefore limited to 1014 bytes even though spi_write accepts 4096. i2c_batch and spi_batch are bounded a third way: their aggregate outgoing bytes must fit one MAX_REQUEST_FRAME (5119-byte) request frame. The refusal is an invalid_params error naming the offending size, the limit and the remedy — not the bare BufferTooLong a device-side refusal would produce. See troubleshooting.

Refusing before connecting is deliberate: connect runs system/reset-subscriptions, which tears down GPIO subscriptions belonging to other host processes, so a mis-sized request must not reach it (issue #104).

spi_batch takes cs as a u8 and runs its steps in a fixed order: parse every operation payload, connect exactly once, read the GPIO count from the DeviceInfo that connection already validated, classify cs, then call the library once. No second metadata query is issued.

Parsing comes first on purpose. connect runs system_reset_subscriptions, which tears down every GPIO subscription on the board — including ones owned by other host processes — so a malformed request must not reach it. A payload that fails to parse returns -32602 (invalid params) without connecting.

An out-of-range cs and a device reporting zero GPIOs are also -32602, with distinct messages, and nothing is transmitted. A failure to establish the count — transport, 300-second device/info timeout, legacy firmware, or schema mismatch — is -32603 (internal error), never -32602: it is not a complaint about your argument.

uart

ToolDescriptionKind
uart_readRead bytes with a timeoutread-only
uart_get_configShow the active UART configurationread-only
uart_writeWrite raw bytesdestructive
uart_flushDrain the transmit bufferdestructive
uart_set_configSet baud rate, data bits, parity, and stop bitsdestructive

uart_set_config requires baud_rate and accepts optional string fields data_bits ("5"–"8"), parity ("none", "odd", "even", "mark", or "space"), stop_bits ("1" or "2"), and serial_number. Omitting a framing field selects its 8N1 power-on value and overwrites the previous value, because the tool replaces the complete configuration. Repeat the framing fields for a baud-only change.

// uart_set_config
{"baud_rate":9600,"data_bits":"7","parity":"even","stop_bits":"2"}

Unlike write and flush, the result is not a bare "ok": it returns the applied baud_rate, data_bits, parity, and stop_bits inside the usual { "serial_number", "result" } envelope. These spellings can be passed directly to a later uart_set_config call. Reconfiguration is not atomic—the divisor is applied before framing and neither direction is drained—so quiesce transmit and receive traffic during the call.

gpio

ToolDescriptionKind
gpio_getRead the current level of a pinread-only
gpio_wait_for_rising_edge_with_timeoutWait for a rising edge (bounded)read-only
gpio_wait_for_falling_edge_with_timeoutWait for a falling edge (bounded)read-only
gpio_wait_for_any_edge_with_timeoutWait for any edge (bounded)read-only
gpio_putDrive a pin high or lowdestructive
gpio_set_configSet direction and pull resistordestructive

pwm

ToolDescriptionKind
pwm_get_duty_cycleRead current and maximum dutyread-only
pwm_get_configShow the active PWM configurationread-only
pwm_set_duty_cycleSet a raw duty-cycle valuedestructive
pwm_enableEnable the slice behind a channeldestructive
pwm_disableDisable the slice behind a channeldestructive
pwm_set_configSet frequency and phase-correct modedestructive

adc (read-only)

ToolDescription
adc_readRead one ADC sample
adc_get_configShow resolution, reference, and channel count

onewire

ToolDescriptionKind
onewire_readRead raw bytesread-only
onewire_searchEnumerate ROM IDs on the busread-only
onewire_resetReset the bus and report presencedestructive
onewire_writeWrite raw bytesdestructive
onewire_write_pullupWrite, then hold the line high (parasitic power)destructive

GPIO waits and v1 limits

GPIO edge waits are timeout-bounded only: each of the three wait tools requires a non-zero timeout_ms. This release deliberately does not expose 0, which would select the firmware’s 30-minute ceiling, or push-based edge subscriptions — holding an MCP request for that full ceiling is undesirable, and event streaming is out of scope for v1. Oversized non-zero values are clamped to the same ceiling; expiry returns the GPIO endpoint’s Timeout error.

uart_read’s timeout_ms is not covered by that rule: there 0 is legal and performs a single non-blocking poll, returning whatever is already buffered (possibly nothing). Its non-zero path is clamped to the 30-minute ceiling. The asymmetry is deliberate.

uart_read is also one of the twelve tools that hard-fail on a hw-rev1 board: that revision supports only I2C, SPI, GPIO, and PWM, so every uart_*, adc_*, and onewire_* call returns Unsupported there. Two boards on one bench can be different revisions, so call device_info per board rather than assuming they are interchangeable.

Security

gallo-mcp does not gate writes itself. Write approval is delegated to the MCP client through tool annotations:

  • read tools are marked readOnlyHint,
  • write and actuation tools are marked destructiveHint.

A well-configured client uses those hints to prompt for confirmation before a destructive tool call. Configure your client’s permissions accordingly.

Warning

Under a permission-less or blanket-allow client, an agent can actuate hardware — drive pins, write buses, change configuration — without confirmation. If the board is wired to anything you care about, run the server behind a client that honors destructiveHint.

Every call resets that board’s GPIO subscriptions

Opening a board tears down every GPIO edge subscription on it, including ones owned by other host processes. A gallo CLI session or a user program watching a pin loses that watch the moment a tool call touches the same board.

That is the documented host protocol rather than a bug — it is how a host recovers pins stranded by a previous host that died mid-watch — but two things about it are easy to miss:

  • readOnlyHint does not cover it. status, device_info, version, ping, and i2c_scan are annotated read-only and appear in the read-only rows of the catalog below, yet each opens a board and so each resets its subscriptions. A client gating on destructiveHint will not prompt for any of them.
  • Per-call selection widened the blast radius. It used to reach only the single board the server was bound to. Now any call can name any attached board, so on a multi-board bench a call carrying serial_number for board B disturbs board B — even if every previous call in the session went to board A.

list_devices is the one exception: it opens no board, so it disturbs nothing.

If a long-lived watch matters, pin the server with -s to keep it away from the board running the watch.

Validation

The server was validated on real hardware: a Pico de Gallo (serial 5256657D8A5D7F03, firmware v0.10.1, schema v0.6.1, HW rev2) with a TMP108 temperature sensor on I2C.

Over stdio, status reports the attached board:

// status {}
{
  "attached": true,
  "serial_number": "5256657D8A5D7F03",
  "ambiguous": false,
  "available": ["5256657D8A5D7F03"],
  "pinned": null,
  "firmware_version": "0.12.0",
  "schema_major": 0,
  "schema_minor": 8,
  "build_id": "firmware-v0.12.0"
}

i2c_scan finds the sensor at address 0x48:

// i2c_scan {"include_reserved":false}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "addresses": ["0x48"], "raw": [72] }
}

And i2c_write_read reads its two temperature bytes:

// i2c_write_read {"address":72,"data":"0x00","count":2}
{
  "serial_number": "5256657D8A5D7F03",
  "result": { "hex": "0x0B,0x8E", "bytes": [11, 142] }
}

Those bytes are byte-for-byte identical to the gallo CLI:

$ gallo i2c write-read -a 0x48 -b 0x00 -c 2
0b 8e

Board selection was validated on the same bench with a second board attached (568E9AAEC72B0D49, bare bus): a bare i2c_scan is refused rather than answered from an arbitrary board, and naming either serial reaches that board. See Choosing a board.

Note

Decoding 0x0B8E into degrees Celsius is left to the reader and the TMP108 datasheet — it is not asserted here. The point of this walkthrough is that the MCP round-trip returns exactly what the CLI does, so an agent driving the board through gallo-mcp sees the same truth you would at the shell.

Writing a Device Driver

Pico de Gallo exists for a very specific kind of loop: write a driver, plug a real part into a board on your desk, run it from your laptop, and iterate fast.

That means:

  • no firmware flashing between every edit
  • no SWD probe on your bench
  • no clock-tree bring-up before you can read one register
  • no linker scripts, BSP setup, or target-specific project scaffolding
  • no throwing your driver away when you change MCUs later

We still write the driver against embedded-hal. The transport happens to be Pico de Gallo today; the same crate can ship on an RP2350, STM32, nRF, ESP, or any other target that implements the same traits.

In this part of the book we will build a driver for the TMP102, a tiny I2C temperature sensor from Texas Instruments. It is a good tutorial device: small register map, readable datasheet, and just enough detail to show where a real driver gets its shape.

We will take the chapter in the same order you would tackle the work in practice:

  1. Explore the device with gallo
  2. Scaffold the crate with the drive-rs template
  3. Describe the register map for code generation
  4. Bridge the generated code to embedded-hal
  5. Build an ergonomic public API
  6. Test it against real hardware
  7. Keep blocking and async users equally happy
  8. Polish it for publishing

The big idea is simple:

Tip

The fastest way to write a solid embedded driver is often to not start on the microcontroller at all. Start on your laptop, with fast builds, rich tooling, and a real sensor on the wire.

By the end, we will have a driver that:

  • speaks TMP102 over I2C
  • uses generated register accessors for the repetitive bits
  • exposes a small, type-safe API for the parts humans care about
  • works with both blocking and async embedded-hal
  • can be tested with a real sensor in hardware-in-the-loop

If you already know Rust and embedded-hal, this chapter should feel like a practical walkthrough rather than a Rust tutorial. We will skip the basics and spend our time on the interesting parts: where the register map comes from, how to make invalid states unrepresentable, and how Pico de Gallo changes the driver-authoring workflow.

Let’s start by interrogating the sensor directly.

Exploring with gallo

Before we write a driver, we want confidence that we understand the part. gallo is perfect for that: it lets us poke at a real device over Pico de Gallo without writing any code yet.

For this walkthrough we are using SparkFun’s Digital Temperature Sensor - TMP102 (Qwiic) breakout board.

The Qwiic pinout documentation confirms the JST-SH wiring:

PinColorSignal
1BlackGround
2Red3.3V
3BlueSDA
4YellowSCL

Wire the board to Pico de Gallo like this:

TMP102 Wiring

Now scan the I2C bus:

$ gallo i2c scan
╭────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────╮
│    │  0 │  1 │  2 │  3 │  4 │  5 │  6 │  7 │  8 │  9 │  a │  b │  c │  d │  e │  f │
├────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┤
│ 0  │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 1  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 2  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 3  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 4  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ 48 │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 5  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 6  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 7  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │
╰────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────╯

The interesting part is address 0x48. TMP102 supports four possible 7-bit addresses depending on how pin A0 is strapped; datasheet table 6-4 gives us:

A0Address
Ground0x48
V+0x49
SDA0x4a
SCL0x4b

So our breakout board almost certainly ties A0 to ground. SparkFun’s schematic confirms exactly that.

That tiny exercise already tells us something useful for the driver API: we should not accept an arbitrary u8 address if the device only has four legal values. We will come back to that later.

Reading the register map

TMP102 only exposes four registers. Datasheet figure 6-2 and table 6-7 show the pointer-register layout:

P1P0Register
00Temperature register (read-only)
01Configuration register (read-write)
10TLOW register (read-write)
11THIGH register (read-write)

That’s a nice register map for a first driver: four addresses, two-byte registers, and a configuration register with a handful of bitfields.

Triggering a one-shot conversion

Datasheet section 6.5.3.6 says setting the one-shot bit (OS) in the configuration register starts a conversion while the device is in shutdown mode.

For a first pass we can avoid a full read-modify-write dance and use the power-on reset value from tables 6-10 and 6-11. The reset contents are:

B7B6B5B4B3B2B1B0
Byte 101100000
Byte 210100000

Setting bit 7 of byte 1 turns 0x60 0xa0 into 0xe0 0xa0, so we can write the configuration register like this:

$ gallo i2c write --address 0x48 --bytes 0x01 0xe0 0xa0

The leading 0x01 is the pointer byte selecting the Configuration register.

Note

In a real driver we will not hard-code reset values like this. We will model the register properly and let the generated API flip only the bits we care about.

Reading the temperature result

The temperature register lives at pointer 0x00, so a write-then-read gets us the raw conversion result:

$ gallo i2c write-read --address 0x48 --bytes 0x00 --count 2
6b 15

TMP102 reports temperature with a resolution of $0.0625,^{\circ}\text{C}$ per least-significant bit. Using the sample above, the same conversion the draft chapter used is:

$$ \frac{5843 \cdot 0.0625}{16} \approx 21.4^{\circ}\text{C} $$

The exact bytes on your desk will differ, of course. The point is not this specific room temperature; the point is that the bus transaction, register selection, and conversion math all line up with the datasheet.

At this stage we know enough to start writing a proper driver:

  • the legal device addresses
  • the register map
  • which register fields deserve names
  • how raw samples become degrees Celsius

That is exactly the information we need for the next step: turning the register map into code.

Scaffolding the crate

This driver is an ordinary Rust library crate. It needs no board support package, cross toolchain, or custom linker setup.

The recommended starting point is drive-rs, a cargo-generate template that scaffolds a no_std, embedded-hal driver crate on top of the device-driver toolkit. After three prompts, it generates a compiling crate with a register map, a transport layer, a high-level API, hardware-free mock tests, and a Pico de Gallo example. No TMP102-specific code is required yet.

To assemble the crate by hand, skip to starting from an empty crate at the end of this chapter.

Prerequisites

Install cargo-generate to run the template. The template’s post-generation hook also uses the ddc compiler from device-driver-cli to turn the register manifest into Rust:

$ cargo install cargo-generate
$ cargo install device-driver-cli

Running the template

$ cargo generate --git https://github.com/OpenDevicePartnership/drive-rs template --allow-commands

The template asks three questions:

  1. Enter the project name in kebab-case. It becomes the crate name, and its PascalCase form becomes the driver struct name.
  2. Select one or more interfaces from gpio, i2c, spi, and uart.
  3. Choose sync, async, or both. The answer is resolved at generation time, not behind a Cargo feature: sync emits only the blocking surface, async only the async one, and both emits the two side by side.

TMP102 is a plain I2C part with no discrete control lines, so select i2c as the only interface. Select both to support blocking and async users:

🤷   Project Name: tmp102
🤷   Which bus/interface(s) does the device use? [i2c]
🤷   Generate blocking, async, or both APIs? both

Generated files

tmp102/
├── Cargo.toml            # embedded-hal + embedded-hal-async + device-driver runtime
├── README.md
├── device.ddsl           # device-driver manifest, the source of truth
├── src/
│   ├── lib.rs            # #![no_std] crate root
│   ├── error.rs          # generic Error<E> that preserves the underlying bus error
│   ├── registers.rs      # generated from device.ddsl by ddc (don't edit by hand)
│   ├── interface.rs      # I2cInterface: bridges embedded-hal to device-driver
│   └── driver.rs         # the high-level Tmp102 type and its constructors
├── tests/
│   └── integration.rs    # mock-based tests, no hardware required
└── examples/
    └── pico.rs           # runs against real hardware over a Pico de Gallo bridge

Because this configuration selects only i2c, the template does not emit code for gpio, spi, or uart. It omits unused interfaces instead of placing them behind feature flags.

The generated Cargo.toml contains the driver dependencies and the host-side test harness:

[package]
name = "tmp102"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
license = "MIT OR Apache-2.0"

[dependencies]
embedded-hal = "1.0"
embedded-hal-async = "1.0"
device-driver = { version = "2.1.0", default-features = false }
defmt = { version = "1", optional = true }

[dev-dependencies]
pico-de-gallo-hal = { version = "0.7", default-features = false }
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }

[features]
default = []
defmt = ["dep:defmt", "device-driver/defmt"]

embedded-hal provides the blocking driver surface, while embedded-hal-async provides the async API. Both are ordinary, non-optional dependencies: the both answer was already resolved when the crate was generated, so there is no async feature to turn on or off. Answering sync would have emitted neither embedded-hal-async nor any async code at all. device-driver supplies the generated register accessors. The optional defmt dependency supports logging on the target.

The development dependencies serve the host-side code: pico-de-gallo-hal lets tests and examples communicate with real hardware, and tokio runs async examples and hardware-in-the-loop tests.

device-driver uses default-features = false. The register map is compiled ahead of time by ddc, so the crate only needs the runtime. Users do not compile its procedural macro or manifest parser.

Tip

Keep pico-de-gallo-hal in [dev-dependencies], not in normal [dependencies]. Your end users should depend on your driver crate, not on the host-side test harness you used while writing it.

Build checks

The generated crate compiles and passes its tests before it contains any TMP102-specific code:

$ cd tmp102
$ cargo test
$ cargo clippy -- -D warnings

It’s no_std, so it builds for a bare-metal target too:

$ rustup target add thumbv7em-none-eabihf
$ cargo build --target thumbv7em-none-eabihf

Next, replace the template’s placeholder register map with the one from the TMP102 datasheet, then build the driver API on top of it.

device.ddsl is the single source of truth for that map. It is written in DDSL, device-driver’s own specification language, and it names the generated device itself. There is no separate device-name flag:

device Tmp102Registers {
    default-byte-order: LE,
    register-address-type: u8,
    ...
}

After editing it to match the datasheet, regenerate src/registers.rs, which is committed to the crate:

$ ddc build rust -s device.ddsl -o src/registers.rs --rust-defmt-feature=defmt

Optional: starting from an empty crate

The template is optional. To add each piece by hand, start with a fresh library:

$ cargo new --lib tmp102
    Creating library `tmp102` package
$ cd tmp102

Then add the dependencies we know we will need:

$ cargo add embedded-hal
$ cargo add embedded-hal-async
$ cargo add device-driver --no-default-features
$ cargo add --dev pico-de-gallo-hal
$ cargo add --dev tokio -F rt-multi-thread,time,macros
$ cargo install device-driver-cli

This produces the same dependency set as the generated crate, but leaves src/lib.rs empty. It does not create error.rs, interface.rs, driver.rs, mock tests, or examples/pico.rs. The following chapters write those files from scratch, so this route involves more typing and teaches more of the process.

Both routes produce a library-first, embedded-hal-based driver with an async API and host-side hardware testing. The next chapter describes the TMP102 registers in the format the generator accepts.

Describe the device for code generation

Hand-writing register boilerplate is dull, repetitive work. TMP102 is small, but it still benefits from code generation.

Open device.ddsl in the crate root and replace the template’s placeholder device with the real TMP102 map. If you started from an empty crate, create the file now.

DDSL is device-driver’s own specification language. One file describes the whole map, including the name of the device itself.

1. The device block

The device node names the generated low-level type and sets the defaults that every object below it inherits.

device Tmp102Registers {
    register-address-type: u8,
    default-byte-order: LE,
    default-access: RW,

    // registers and fieldsets go here
}
  • register-address-type is the type of a register address. TMP102’s pointer register is 8 bits, so u8.
  • default-byte-order sets the byte order for fieldsets that do not declare one.
  • default-access sets the access for objects that do not declare one.

The compiler works out word boundaries on its own, so Tlow becomes tlow() and SD becomes sd().

2. Register declarations

TMP102 has four registers. Three of them, the reading and the two alert limits, share a single layout, so declare that layout once as a named fieldset and point all three registers at it.

    /// Left-justified temperature code, transmitted MSB first.
    ///
    /// 12 significant bits in normal mode, 13 in extended mode.
    fieldset TemperatureCode {
        size-bytes: 2,
        byte-order: BE,

        /// The raw 16-bit register value.
        field value 15:0 -> uint,
    },

    /// Temperature register.
    register Temperature {
        address: 0,
        access: RO,
        fields: TemperatureCode,
    },

    /// T-low register.
    register Tlow {
        address: 2,
        fields: TemperatureCode,
    },

    /// T-high register.
    register Thigh {
        address: 3,
        fields: TemperatureCode,
    },

byte-order: BE overrides the device default for these three registers only, because TMP102 sends the temperature high byte first. access: RO on Temperature leaves the generated operation without a write method, so the datasheet’s read-only rule shows up as a compile error.

3. Configuration bitfields

The configuration register is a pile of enums packed into bit ranges. Its layout is used once, so define the fieldset inline; fieldset _ gives the generated type the name of the register it sits in.

    /// Configuration register.
    register Configuration {
        address: 1,
        fields: fieldset _ {
            size-bytes: 2,

            /// Shutdown mode.
            field SD 0 -> _ as enum ShutdownMode {
                Running: 0,
                PowerOff: 1,
            },
            /// Thermostat mode of operation.
            field TM 1 -> _ as enum ThermostatMode {
                Comparator: 0,
                Interrupt: 1,
            },
            /// Alert pin polarity.
            field POL 2 -> _ as enum Polarity {
                ActiveLow: 0,
                ActiveHigh: 1,
            },
            /// Fault queue depth.
            field F 4:3 -> _ as enum FaultQueue {
                One: 0,
                Two: 1,
                Four: 2,
                Six: 3,
            },
            /// Converter resolution.
            field R 6:5 RO -> uint,
            /// One-shot conversion.
            field OS 7 -> bool,
            /// Extended mode.
            field EM 12 -> _ as enum ExtendedMode {
                Disable: 0,
                Enable: 1,
            },
            /// Alert.
            field AL 13 -> bool,
            /// Conversion rate.
            field CR 15:14 -> _ as enum ConversionRate {
                QuarterHz: 0,
                OneHz: 1,
                FourHz: 2,
                EightHz: 3,
            },
        },
    },

Bit ranges are written high-to-low and are inclusive, so 15:14 is the two-bit conversion rate and a bare 0 is the single shutdown bit. The _ before as lets each field take its base type from the enum it converts to.

That means:

  • SD stops being a magic bit and becomes a ShutdownMode
  • CR stops being 0b10 and becomes ConversionRate::FourHz
  • R carries RO, so the fieldset gets r() but no set_r()
  • every enum covers all bit patterns of its field, so sd() and cr() return the enum itself rather than a Result

Tip

Keep the manifest focused on register truth, not ergonomic policy. It describes what the hardware is; the public driver API decides what is pleasant and safe to call.

4. Generate src/registers.rs

With the manifest in place, generate the low-level register interface:

$ ddc build rust -s device.ddsl -o src/registers.rs --rust-defmt-feature=defmt

The device node supplies the name, so the generated low-level type is Tmp102Registers.

The generated file is intentionally not the public API. We will treat it as an implementation detail:

  • registers.rs knows registers, fields, and access widths
  • our hand-written wrapper will know addresses, conversions, and human-facing methods

Let the generator write the repetitive code and keep the policy decisions in the part you maintain.

Next we connect that generated layer to a real I2C bus.

Implementing RegisterInterface and AsyncRegisterInterface

At this point we have generated code, but it still has no idea how to reach real hardware. That is our job.

Tmp102Registers wants a tiny transport object that knows how to read and write a register by address. For TMP102 that transport is just I2C plus the selected device address.

TMP102 does not live at an arbitrary address. Datasheet table 6-4 gives us four legal values, so we should model exactly those four values.

/// Logic level wired onto the TMP102 A0 pin.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum A0 {
    /// A0 tied to ground (`0x48`).
    #[default]
    Gnd,
    /// A0 tied to V+ (`0x49`).
    Vplus,
    /// A0 tied to SDA (`0x4a`).
    Sda,
    /// A0 tied to SCL (`0x4b`).
    Scl,
}

impl From<A0> for u8 {
    fn from(a0: A0) -> Self {
        match a0 {
            A0::Gnd => 0x48,
            A0::Vplus => 0x49,
            A0::Sda => 0x4a,
            A0::Scl => 0x4b,
        }
    }
}

That alone eliminates a whole class of mistakes. The caller can no longer accidentally pass 0x52 and then wonder why the driver never sees an ACK.

Wrap the bus

Now create the transport type that the generated layer will sit on top of:

use embedded_hal::i2c::I2c;
use embedded_hal_async::i2c::I2c as AsyncI2c;

struct Interface<I2C> {
    i2c: I2C,
    addr: u8,
}

impl<I2C> Interface<I2C> {
    fn new(i2c: I2C, a0: A0) -> Self {
        Self {
            i2c,
            addr: a0.into(),
        }
    }
}

RegisterInterfaceBase declares the two associated types that the blocking and async traits are both built on:

use device_driver::RegisterInterfaceBase;
use embedded_hal::i2c::ErrorKind;

impl<I2C> RegisterInterfaceBase for Interface<I2C> {
    type Error = ErrorKind;
    type AddressType = u8;
}

AddressType has to match the register-address-type we set in the manifest. ErrorKind comes from embedded-hal, and embedded-hal-async re-exports that same type, so one impl covers both sides and needs no bound on I2C.

The job of Interface is deliberately boring: take register operations from generated code and translate them into real I2C transactions.

Blocking register access

For blocking embedded-hal, that translation looks like this:

use device_driver::{FieldsetMetadata, RegisterInterface};
use embedded_hal::i2c::{Error, I2c};

impl<I2C: I2c> RegisterInterface for Interface<I2C> {
    fn write_register(
        &mut self,
        address: Self::AddressType,
        data: &mut [u8],
        _metadata: &FieldsetMetadata,
    ) -> Result<(), Self::Error> {
        let mut buf = [0u8; 3];
        buf[0] = address;
        buf[1..].copy_from_slice(data);

        self.i2c.write(self.addr, &buf).map_err(|e| e.kind())
    }

    fn read_register(
        &mut self,
        address: Self::AddressType,
        data: &mut [u8],
        _metadata: &FieldsetMetadata,
    ) -> Result<(), Self::Error> {
        self.i2c
            .write_read(self.addr, &[address], data)
            .map_err(|e| e.kind())
    }
}

TMP102 keeps this pleasantly simple: one pointer byte, then two bytes of payload.

Both methods take data as &mut [u8]. The write side gets a mutable buffer so that an interface can rearrange bytes in place before sending, which we do not need here. _metadata carries the fieldset’s byte order, which we can also ignore because the framing is the same for every TMP102 register.

Note

The fixed [u8; 3] buffer is TMP102-specific. For a bigger device with wider registers, size the stack buffer to your largest write or switch to a small growable buffer type.

Async register access

The async version is the same idea with .await in the obvious places. Because the template resolved both at generation time, this impl sits next to the blocking one with no cfg between them:

use device_driver::AsyncRegisterInterface;

impl<I2C: AsyncI2c> AsyncRegisterInterface for Interface<I2C> {
    async fn write_register(
        &mut self,
        address: Self::AddressType,
        data: &mut [u8],
        _metadata: &FieldsetMetadata,
    ) -> Result<(), Self::Error> {
        let mut buf = [0u8; 3];
        buf[0] = address;
        buf[1..].copy_from_slice(data);

        self.i2c.write(self.addr, &buf).await.map_err(|e| e.kind())
    }

    async fn read_register(
        &mut self,
        address: Self::AddressType,
        data: &mut [u8],
        _metadata: &FieldsetMetadata,
    ) -> Result<(), Self::Error> {
        self.i2c
            .write_read(self.addr, &[address], data)
            .await
            .map_err(|e| e.kind())
    }
}

So far, so good. The generated code can finally talk to the sensor.

Put the generated layer behind a real driver type

Build this as it stands and the compiler will report that fields and methods on Tmp102Registers are never used. It is right: we generated a low-level API and nothing calls it yet.

A minimal wrapper clears those warnings and gives the chapter a place to keep growing:

mod registers;

use registers::{ShutdownMode, Tmp102Registers};

pub struct Tmp102<I2C> {
    inner: Tmp102Registers<Interface<I2C>>,
    extended_mode: bool,
}

impl<I2C> Tmp102<I2C> {
    pub fn new(i2c: I2C, a0: A0) -> Self {
        Self {
            inner: Tmp102Registers::new(Interface::new(i2c, a0)),
            extended_mode: false,
        }
    }
}

impl<I2C: I2c> Tmp102<I2C> {
    pub fn raw_temperature_register(&mut self) -> Result<[u8; 2], ErrorKind> {
        Ok(self.inner.temperature().read()?.into())
    }

    pub fn configure_shutdown_bit(&mut self, shutdown: bool) -> Result<(), ErrorKind> {
        self.inner.configuration().modify(|reg| {
            reg.set_sd(if shutdown {
                ShutdownMode::PowerOff
            } else {
                ShutdownMode::Running
            })
        })
    }

    pub fn set_low_limit_raw(&mut self, raw: [u8; 2]) -> Result<(), ErrorKind> {
        self.inner.tlow().write(|reg| *reg = raw.into())
    }

    pub fn set_high_limit_raw(&mut self, raw: [u8; 2]) -> Result<(), ErrorKind> {
        self.inner.thigh().write(|reg| *reg = raw.into())
    }
}

impl<I2C: AsyncI2c> Tmp102<I2C> {
    pub async fn raw_temperature_register_async(&mut self) -> Result<[u8; 2], ErrorKind> {
        Ok(self.inner.temperature().read_async().await?.into())
    }
}

Now inner.temperature(), inner.configuration(), inner.tlow(), and inner.thigh() are all exercised through Tmp102, so those dead-code warnings disappear for the right reason: the generated layer is no longer orphaned.

This wrapper is still too raw for real use, but that is fine. The next step is where the driver starts feeling like a crate you would actually publish.

Make invalid states unrepresentable

The generated API is accurate, but it is not the API we want to hand to users. Driver users do not want to think in pointer bytes and raw 16-bit register images; they want to ask for a temperature, configure alert limits, and move the device between running and shutdown modes.

This is the point where we decide what the public crate feels like.

Start with a temperature type

A plain f32 works, but it tells the caller nothing about units. A tiny newtype fixes that immediately:

#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct Celsius(pub f32);

Now a return value of Celsius(21.5) is obviously a temperature and not an arbitrary calibration constant.

Collect configuration into a builder

Instead of making users pass four unrelated arguments into configure(...), give them a small builder with good defaults. The four fields are enums ddc generated from the manifest.

use registers::{ConversionRate, ExtendedMode, Polarity, ThermostatMode};

#[derive(Clone, Copy, Debug)]
pub struct Config {
    thermostat_mode: ThermostatMode,
    polarity: Polarity,
    extended_mode: ExtendedMode,
    conversion_rate: ConversionRate,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            thermostat_mode: ThermostatMode::Comparator,
            polarity: Polarity::ActiveLow,
            extended_mode: ExtendedMode::Disable,
            conversion_rate: ConversionRate::FourHz,
        }
    }
}

impl Config {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn thermostat_mode(mut self, mode: ThermostatMode) -> Self {
        self.thermostat_mode = mode;
        self
    }

    pub fn polarity(mut self, polarity: Polarity) -> Self {
        self.polarity = polarity;
        self
    }

    pub fn extended_mode(mut self, mode: ExtendedMode) -> Self {
        self.extended_mode = mode;
        self
    }

    pub fn conversion_rate(mut self, rate: ConversionRate) -> Self {
        self.conversion_rate = rate;
        self
    }
}

That lets the call site read cleanly:

let config = Config::new()
    .extended_mode(ExtendedMode::Enable)
    .conversion_rate(ConversionRate::EightHz);

Use typestate for run vs shutdown

TMP102 has two materially different operating states:

  • running, where the sensor converts continuously
  • shutdown, where it sleeps until explicitly kicked

A single Tmp102 type with a runtime boolean would work, but typestate lets us express the distinction in the type system.

use core::marker::PhantomData;

use registers::Tmp102Registers;

pub struct Running;
pub struct Shutdown;

pub struct Tmp102<I2C, State = Running> {
    inner: Tmp102Registers<Interface<I2C>>,
    extended_mode: bool,
    _state: PhantomData<State>,
}

The extra state parameter means we can say “this method only exists when the sensor is running” and let the compiler enforce it.

A small helper keeps state transitions tidy:

impl<I2C, State> Tmp102<I2C, State> {
    fn change_state<Next>(self) -> Tmp102<I2C, Next> {
        Tmp102 {
            inner: self.inner,
            extended_mode: self.extended_mode,
            _state: PhantomData,
        }
    }
}

Constructors and shared helpers

We will make continuous-conversion mode the default constructor:

impl<I2C> Tmp102<I2C, Running> {
    pub fn new(i2c: I2C, a0: A0) -> Self {
        Self::continuous(i2c, a0)
    }

    pub fn continuous(i2c: I2C, a0: A0) -> Self {
        Self {
            inner: Tmp102Registers::new(Interface::new(i2c, a0)),
            extended_mode: false,
            _state: PhantomData,
        }
    }

    fn decode_temperature(raw: [u8; 2], extended_mode: bool) -> Celsius {
        let mut value = i16::from_be_bytes(raw);

        value /= if extended_mode { 8 } else { 16 };
        Celsius(value as f32 * 0.0625)
    }

    fn encode_limit(limit: Celsius) -> [u8; 2] {
        ((limit.0 / 0.0625) as i16 * 16).to_be_bytes()
    }
}

Those helpers keep all the datasheet math in one place instead of smearing it across every high-level method.

The high-level async API

Here is the shape we are after for the async path:

use registers::ShutdownMode;

impl<I2C: AsyncI2c> Tmp102<I2C, Running> {
    pub async fn configure(&mut self, config: Config) -> Result<(), ErrorKind> {
        self.extended_mode = config.extended_mode == ExtendedMode::Enable;

        self.inner
            .configuration()
            .modify_async(|reg| {
                reg.set_tm(config.thermostat_mode);
                reg.set_pol(config.polarity);
                reg.set_em(config.extended_mode);
                reg.set_cr(config.conversion_rate);
            })
            .await
    }

    pub async fn set_low_limit(&mut self, limit: Celsius) -> Result<(), ErrorKind> {
        let raw = Self::encode_limit(limit);
        self.inner.tlow().write_async(|reg| *reg = raw.into()).await
    }

    pub async fn set_high_limit(&mut self, limit: Celsius) -> Result<(), ErrorKind> {
        let raw = Self::encode_limit(limit);
        self.inner.thigh().write_async(|reg| *reg = raw.into()).await
    }

    pub async fn temperature(&mut self) -> Result<Celsius, ErrorKind> {
        let raw: [u8; 2] = self.inner.temperature().read_async().await?.into();
        Ok(Self::decode_temperature(raw, self.extended_mode))
    }

    pub async fn shutdown(mut self) -> Result<Tmp102<I2C, Shutdown>, ErrorKind> {
        self.inner
            .configuration()
            .modify_async(|reg| reg.set_sd(ShutdownMode::PowerOff))
            .await?;

        Ok(self.change_state())
    }
}

impl<I2C: AsyncI2c> Tmp102<I2C, Shutdown> {
    pub async fn run(mut self) -> Result<Tmp102<I2C, Running>, ErrorKind> {
        self.inner
            .configuration()
            .modify_async(|reg| reg.set_sd(ShutdownMode::Running))
            .await?;

        Ok(self.change_state())
    }

    pub async fn configure(&mut self, config: Config) -> Result<(), ErrorKind> {
        self.extended_mode = config.extended_mode == ExtendedMode::Enable;

        self.inner
            .configuration()
            .modify_async(|reg| {
                reg.set_tm(config.thermostat_mode);
                reg.set_pol(config.polarity);
                reg.set_em(config.extended_mode);
                reg.set_cr(config.conversion_rate);
            })
            .await
    }
}

The important part is not the exact spelling; it is the contract:

  • temperatures come back as Celsius
  • alert thresholds are set in Celsius
  • configure() accepts one coherent settings object
  • shutdown() consumes Tmp102<_, Running>
  • run() gives you back Tmp102<_, Running>

That makes illegal flows hard to write. You cannot accidentally call the “running-only” API on a shutdown sensor because the type no longer matches.

Final public shape

This is the surface we want readers to remember:

pub enum A0 { Gnd, Vplus, Sda, Scl }
pub struct Celsius(pub f32);
pub struct Config { /* builder-style setters */ }
pub struct Running;
pub struct Shutdown;
pub struct Tmp102<I2C, State = Running> { /* private fields */ }

impl<I2C> Tmp102<I2C, Running> {
    pub fn new(i2c: I2C, a0: A0) -> Self;
    pub fn continuous(i2c: I2C, a0: A0) -> Self;
}

impl<I2C: AsyncI2c> Tmp102<I2C, Running> {
    pub async fn configure(&mut self, config: Config) -> Result<(), ErrorKind>;
    pub async fn set_high_limit(&mut self, limit: Celsius) -> Result<(), ErrorKind>;
    pub async fn set_low_limit(&mut self, limit: Celsius) -> Result<(), ErrorKind>;
    pub async fn temperature(&mut self) -> Result<Celsius, ErrorKind>;
    pub async fn shutdown(self) -> Result<Tmp102<I2C, Shutdown>, ErrorKind>;
}

impl<I2C: AsyncI2c> Tmp102<I2C, Shutdown> {
    pub async fn run(self) -> Result<Tmp102<I2C, Running>, ErrorKind>;
}

Tip

The alternative is a single Tmp102<I2C> with a runtime bool that tracks shutdown state. That keeps the type simpler, but it also pushes more mistakes to runtime. For a tiny driver like TMP102, the extra typestate surface is worth it.

Next we put that API to work against real hardware.

Testing with pico-de-gallo-hal

This is the whole payoff.

Because the driver is written against embedded-hal, and because pico-de-gallo-hal implements those traits on top of a USB-connected Pico de Gallo board, we can run the driver on a host machine against a real TMP102.

No mocks. No firmware flashing loop. No sacrificial example binary that only exists so you can manually test one register read.

A hardware-in-the-loop test

Our public API is async, so the test is too:

#![allow(unused)]
fn main() {
#[cfg(feature = "hil")]
#[tokio::test]
async fn tmp102_reports_a_plausible_temperature() {
    use pico_de_gallo_hal::Hal;
    use tmp102::{Celsius, Tmp102, A0};

    let hal = Hal::new();
    let i2c = hal.i2c();
    let mut sensor = Tmp102::new(i2c, A0::Gnd);

    let Celsius(temp) = sensor.temperature().await.unwrap();

    assert!((-40.0..=125.0).contains(&temp));
}
}

That range is intentionally broad: it matches the device’s operating range and keeps the test robust across different lab environments.

The blocking siblings we add in the next chapter work the same way with a plain #[test] and no .await.

Why this is special

Most embedded-driver test setups force you into one of two extremes:

  • pure mocks, which are fast but can only prove your expectations about bus traffic
  • target-hardware tests, which are realistic but slow and usually drag in flashing, runners, probes, and target-specific setup

Pico de Gallo sits in a very productive middle ground:

  • the sensor is real
  • the electrical path is real
  • the I2C transactions are real
  • the test still runs from your normal host-side Rust test harness

That means you can put a USB-connected board on a CI runner and execute real hardware-in-the-loop tests with cargo test.

Gate HIL tests behind a feature

Not every CI environment has hardware attached, so gate the test behind an opt-in feature:

#[cfg(feature = "hil")]
#[tokio::test]
async fn tmp102_reports_a_plausible_temperature() {
    // ...
}

And in Cargo.toml:

[features]
default = []
defmt = ["dep:defmt", "device-driver/defmt"]
hil = []

Now normal CI can run cargo test, while the hardware-backed runner can run:

$ cargo test --features hil

Tip

Keep pico-de-gallo-hal in [dev-dependencies]. End users of your published crate do not need the host transport layer just because you used it for tests.

Mocks still have a place

pico-de-gallo-hal is not a replacement for embedded-hal-mock. Instead, the two complement each other nicely:

  • use embedded-hal-mock for pure logic tests, edge cases, and exact bus-sequence assertions
  • use Pico de Gallo for integration tests against a real sensor

That combination is hard to beat:

  • mocks keep your fast inner loop fast
  • HIL tests catch the mistakes that only show up with actual hardware

For driver authors, that is exactly why pico-de-gallo-hal exists.

Blocking vs async parity

One of the nicest things about building on embedded-hal is that you do not have to choose one execution model forever.

pico-de-gallo-hal::I2c implements both the blocking embedded_hal::i2c::I2c trait and the async embedded_hal_async::i2c::I2c trait. So the exact same TMP102 driver can be used from synchronous code and from async code.

Share the hard parts

The register definitions, address handling, temperature conversions, and configuration builder do not care whether the bus is blocking or async. Keep those pieces shared:

impl<I2C> Tmp102<I2C, Running> {
    fn decode_temperature(raw: [u8; 2], extended_mode: bool) -> Celsius {
        let mut value = i16::from_be_bytes(raw);
        value /= if extended_mode { 8 } else { 16 };
        Celsius(value as f32 * 0.0625)
    }

    fn encode_limit(limit: Celsius) -> [u8; 2] {
        ((limit.0 / 0.0625) as i16 * 16).to_be_bytes()
    }
}

Then make the blocking and async fronts as thin as possible.

A simple parity pattern

Because some I2C types implement both traits, duplicating the same inherent method names can get awkward. The least surprising pattern for a small driver is:

  • keep the ergonomic async API as the primary surface
  • add thin blocking siblings with _blocking suffixes
  • keep all encoding and decoding logic in shared helpers
impl<I2C: embedded_hal::i2c::I2c> Tmp102<I2C, Running> {
    pub fn temperature_blocking(&mut self) -> Result<Celsius, ErrorKind> {
        let raw: [u8; 2] = self.inner.temperature().read()?.into();
        Ok(Self::decode_temperature(raw, self.extended_mode))
    }

    pub fn configure_blocking(&mut self, config: Config) -> Result<(), ErrorKind> {
        self.extended_mode = config.extended_mode == ExtendedMode::Enable;

        self.inner.configuration().modify(|reg| {
            reg.set_tm(config.thermostat_mode);
            reg.set_pol(config.polarity);
            reg.set_em(config.extended_mode);
            reg.set_cr(config.conversion_rate);
        })
    }
}

impl<I2C: embedded_hal_async::i2c::I2c> Tmp102<I2C, Running> {
    pub async fn temperature(&mut self) -> Result<Celsius, ErrorKind> {
        let raw: [u8; 2] = self.inner.temperature().read_async().await?.into();
        Ok(Self::decode_temperature(raw, self.extended_mode))
    }

    pub async fn configure(&mut self, config: Config) -> Result<(), ErrorKind> {
        self.extended_mode = config.extended_mode == ExtendedMode::Enable;

        self.inner
            .configuration()
            .modify_async(|reg| {
                reg.set_tm(config.thermostat_mode);
                reg.set_pol(config.polarity);
                reg.set_em(config.extended_mode);
                reg.set_cr(config.conversion_rate);
            })
            .await
    }
}

If you want identical method names on both sides, a macro-based approach such as maybe-async-cfg is a good next step. For a first driver, though, the explicit version is easier to read and maintain.

Same driver, blocking usage

use pico_de_gallo_hal::Hal;
use tmp102::{Celsius, Tmp102, A0};

fn main() -> Result<(), embedded_hal::i2c::ErrorKind> {
    let hal = Hal::new();
    let i2c = hal.i2c();
    let mut sensor = Tmp102::continuous(i2c, A0::Gnd);

    let Celsius(temp) = sensor.temperature_blocking()?;
    println!("{temp:.2} °C");
    Ok(())
}

Same driver, async usage

Same crate, same build, no extra feature flag — just an executor:

use pico_de_gallo_hal::Hal;
use tmp102::{Celsius, Tmp102, A0};

#[tokio::main]
async fn main() -> Result<(), embedded_hal::i2c::ErrorKind> {
    let hal = Hal::new();
    let i2c = hal.i2c();
    let mut sensor = Tmp102::continuous(i2c, A0::Gnd);

    let Celsius(temp) = sensor.temperature().await?;
    println!("{temp:.2} °C");
    Ok(())
}

That is the payoff: same driver type, same register model, same public concepts, two execution models.

When async is worth it

For one occasional temperature read, blocking code is often perfectly fine.

Async starts paying for itself when your program wants to interleave sensor access with other work, for example:

  • polling several devices on one executor
  • serving a network API while reading sensors
  • overlapping I/O-bound work with unrelated futures
  • driving a GUI or TUI without stalling the event loop

The nice part is that you do not have to guess up front. A well-shaped TMP102 driver can serve both audiences.

Publishing the driver

Once the driver feels good locally, spend ten extra minutes making it a crate other people will trust.

Cargo metadata

Start with the basics in Cargo.toml:

[package]
name = "tmp102"
version = "0.1.0"
edition = "2024"
description = "embedded-hal driver for the TMP102 I2C temperature sensor"
license = "MIT OR Apache-2.0"
repository = "https://github.com/OpenDevicePartnership/pico-de-gallo"
categories = ["embedded", "hardware-support", "no-std"]
keywords = ["tmp102", "temperature", "sensor", "i2c", "embedded-hal"]

If the crate is no_std, say so clearly in the README and crate docs. The generated crate resolves blocking versus async when it is scaffolded rather than behind a Cargo feature, so state which of the two surfaces it exposes right next to the first example.

docs.rs

If your docs need specific features enabled, tell docs.rs explicitly. Here the only optional features are defmt and hil, so turning everything on is the simplest correct answer:

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

That avoids the common “works locally, missing items on docs.rs” trap.

README essentials

At minimum, include:

  • what the device is
  • which traits the crate implements or expects
  • a blocking example
  • an async example if you support one
  • feature flags (defmt, and the hil gate from the previous chapter)
  • wiring or address-selection notes for A0

For this driver, also mention that pico-de-gallo-hal is only used for examples and tests; it should stay in [dev-dependencies] so downstream users do not pay for it.

defmt support

The template already wired this up, and it spans two places that have to agree. Cargo.toml keeps the dependency optional and forwards the feature to the runtime:

[dependencies]
defmt = { version = "1", optional = true }

[features]
defmt = ["dep:defmt", "device-driver/defmt"]

The other half is the --rust-defmt-feature=defmt we passed to ddc. That is what puts #[cfg(feature = "defmt")] and #[cfg_attr(feature = "defmt", derive(defmt::Format))] in registers.rs. Rename the Cargo feature without regenerating and the generated code silently stops matching it.

Release hygiene

Three last bits of boring professionalism matter a lot:

  • keep a CHANGELOG.md
  • follow semver when you change the public API
  • commit src/registers.rs and let it into the package

Small drivers live a long time. A clean README, useful crate metadata, and predictable releases do more for adoption than one more clever type.

Architecture

At a high level, Pico de Gallo is a typed RPC bridge between a host computer and an RP2350 microcontroller. The host talks USB; the firmware talks peripherals. The wire contract in the middle is shared Rust code.

host app / script / test
        |
        +--> pico-de-gallo-lib  (async Rust client)
        +--> gallo             (CLI)
        +--> pico-de-gallo-ffi (C ABI)
        +--> pyco-de-gallo     (Python bindings)
                     |
                     v
                   nusb
                     |
                 USB cable
                     |
                     v
        pico-de-gallo-firmware (Embassy on RP2350)
                     |
      +--------------+--------------+--------------+
      |              |              |              |
     I2C            SPI            UART        GPIO/PWM/ADC/1-Wire
      |              |              |              |
      +--------------------- external hardware --------------------+

Three design choices make that stack work.

Why postcard-rpc?

Pico de Gallo uses postcard-rpc because it keeps both sides honest:

  • the request and response types live in one shared crate,
  • endpoints are schema-typed instead of stringly typed APIs,
  • the protocol is transport-agnostic even though Pico de Gallo currently uses USB.

That shared crate is pico-de-gallo-internal. Both the firmware and every host surface depend on it, so the same Rust types define the wire format everywhere.

Tip

This is why new protocol features tend to start in pico-de-gallo-internal: once the type exists there, the rest of the stack can wire it through.

Why Embassy?

The firmware is built on Embassy, which gives Pico de Gallo an async executor on bare metal. That matters because USB traffic, interrupt-driven peripherals, DMA-backed transfers, and GPIO event monitoring all need to coexist without a giant polling loop.

Embassy also maps well to the hardware roles here:

  • async I2C and SPI transfers,
  • interrupt-driven UART,
  • low-jitter timing primitives,
  • background tasks for server dispatch and GPIO event publishing.

Why a host-side embedded-hal shim?

pico-de-gallo-hal adapts the RPC client into familiar embedded-hal and embedded-io traits. The goal is simple: write a driver once, then exercise it from your laptop before you ever flash target firmware.

That is the main idea behind Pico de Gallo as a project: move driver iteration from a flash-debug cycle to a normal host test loop.

The trust boundary

The host trusts the firmware to validate requests before touching hardware. That is an intentional boundary, and the firmware enforces it: handlers reject out-of-range pins, oversized buffers, unsupported peripherals on hw-rev1, and invalid configuration values before they access the RP2350 peripherals.

Important

Validation lives at the firmware edge because the firmware is the part that knows the real hardware limits. Host code should still be well-behaved, but the last line of defense is on-device.

For contributor-only detail, see AGENTS.md and CONTRIBUTING.md.

Wire Protocol & Schema Versioning

The wire protocol is the most important compatibility contract in Pico de Gallo. All protocol types live in pico-de-gallo-internal, and both the host and the firmware compile against that crate.

postcard encoding rules

Pico de Gallo uses postcard for compact binary encoding. That brings one rule that every contributor must understand.

Important

postcard encodes enum variants by variant index, not by the numeric value in #[repr(...)]. Reordering variants in a wire-visible enum is a silent ABI break.

So this is safe:

  • append a new enum variant at the end.

And this is breaking:

  • reorder variants,
  • remove a variant,
  • rename a variant that old peers still expect.

The warning comments in pico-de-gallo-internal are there for a reason; keep them.

The shared protocol crate

pico-de-gallo-internal defines:

  • endpoint marker types,
  • request and response structs,
  • topic message types,
  • directional payload constants MAX_TRANSFER_SIZE and MAX_RESPONSE_PAYLOAD,
  • schema-version constants generated at build time.

The crate also uses the use-std feature to switch certain response types between owned host buffers and borrowed firmware buffers:

  • host build: Vec<u8>
  • firmware build: &[u8]

That lets the host own received data while the firmware can answer from a shared scratch buffer without heap allocation.

The two payload constants are independent and selected by byte direction:

DirectionConstantValue
host → deviceMAX_TRANSFER_SIZE4096
device → hostMAX_RESPONSE_PAYLOAD1014

MAX_RESPONSE_PAYLOAD is derived from postcard-rpc’s 1024-byte host inbound USB buffer: 1024 − 7 bytes of response header (1 discriminant, 2 key, 4 sequence) − 1 postcard Result discriminant − 2 bytes of varint length prefix = 1014. It is a host-transport property that firmware cannot observe. Full-duplex spi/transfer proves why both constants are needed: its one length travels in both directions, so the tighter response ceiling applies.

A third constant, MAX_REQUEST_FRAME (5119), bounds the whole request frame rather than any one argument. It only binds the batch endpoints; the section below derives it.

Issues #158 and #186 added local validation and firmware bounds without changing an endpoint, request or response type. Neither is therefore a wire change, and neither required a schema change or version bump.

The request frame ceiling

MAX_TRANSFER_SIZE bounds a single argument. Independently of it, the whole request frame — header plus postcard body — has to fit in the firmware’s receive buffer, which is MAX_TRANSFER_SIZE + 1024 = 5120 bytes (BufStorage in crates/pico-de-gallo-firmware/src/main.rs).

A frame that does not fit is discarded silently. postcard-rpc’s server maps WireRxErrorKind::ReceivedMessageTooLarge to continue, so nothing is sent back; the caller sees only its own timeout. That is the failure mode to expect here, not BufferTooLong.

The largest frame the firmware accepts is one byte less than the buffer:

BytesTerm
5120firmware receive buffer, MAX_TRANSFER_SIZE + 1024
-1a frame that exactly fills the buffer is refused
= 5119largest accepted request frame

That -1 is a mechanism, not a safety margin. postcard-rpc’s EUsbWireRx::receive ends a frame when a short USB packet arrives, and loops while !window.is_empty(). A frame whose length equals the buffer size fills the window with full 64-byte packets, so the loop runs out of window before it ever sees a short packet, falls into its “ran out of space” branch, reads the transfer-terminating zero-length packet there, and reports ReceivedMessageTooLarge. Frames that are a multiple of 64 but smaller than the buffer are unaffected, because window remains and the zero-length packet ends the frame in the normal path.

What is left for the payload depends on the header, which is not a constant:

BytesTerm
5119largest accepted request frame
-1header discriminant
-8 or -2header key — see below
-4header sequence number (VarSeq::Seq4, always; VarSeq::resize is defined in postcard-rpc 0.12.1 and called nowhere in it, so the VarSeqKind this repo passes never takes effect)
-Nwhatever the request struct encodes around the payload — for a bare &[u8] field that is a 2-byte postcard varint length prefix

postcard-rpc narrows the key width over the life of a connection. HostClient starts at VarKeyKind::Key8 and adopts the server’s narrower key only after it has received a reply; this device’s server answers with Key2. So a process’s first request carries a 13-byte header and every request after the first reply carries 7, and the usable payload is six bytes smaller until then. A request that is itself dropped does not narrow anything, because no reply came back.

For an endpoint whose request is a single byte slice, that works out to:

StateHeaderLargest payload
before the first reply135104
after the first reply75110

No supported single-argument call can reach this. Since #158 every host surface caps a write argument at MAX_TRANSFER_SIZE, which puts the worst-case frame a little over 4110 bytes — a kilobyte clear of the ceiling. pico-de-gallo-internal asserts that ordering at compile time, so it cannot quietly stop being true.

Batches were the exception, because nothing bounded their aggregate write bytes: i2c/batch and spi/batch could build an over-ceiling frame and lose it silently. Since #186 both are bounded host-side against MAX_REQUEST_FRAME before transmission, using the wide header so the answer does not depend on whether the client has received a reply yet. The firmware cannot help here — it never sees the frame — which is what makes this the one ceiling with no device-side counterpart. See Transaction Batching.

Measured on board 49742081C885AC69 (hw-rev2, firmware firmware-v0.11.0-79-gc3c6a3e07bec) under Linux/nusb, and previously on 5256657D8A5D7F03 under Windows/WinUSB with identical edges — issue #180. The 5119/5120 edge was re-measured on firmware-v0.11.0-88-gfcb38de3a1ff for issue #186, with the new host guard stubbed out, and was unchanged.

Endpoints and topics

Endpoints are normal request/response RPCs declared with endpoints!. Topics are push-style messages declared with topics!.

In practice:

  • endpoints cover commands like i2c/read, spi/transfer, and device/info,
  • topics cover asynchronous server-to-client events.

Today the main topic is GPIO event streaming:

KindExampleDirectionPurpose
Endpointi2c/readhost → device → hostRequest/response RPC
Endpointdevice/infohost → device → hostCompatibility probe
Topicgpio/eventdevice → hostPush edge notifications

A short slice of the endpoint catalog looks like this:

PathWhat it does
pingEcho test payload
versionReport firmware version
device/infoReport firmware/schema versions, hardware revision, capabilities, runtime GPIO count, and build identity
i2c/readRead from an I2C target
spi/transferFull-duplex SPI transfer
gpio/subscribeStart GPIO event monitoring

For the full list, see the Endpoint Catalog.

I²C batch framing contract

i2c/batch executes the complete operation list as one I2C transaction:

  • a START and address precede the first operation,
  • adjacent operations of the same type run back to back without a STOP or repeated START; two adjacent writes therefore form one gather write,
  • a direction change emits a repeated START and re-addresses the target,
  • a STOP follows the last operation, and only the last operation.

The repeated START on a direction change is documented by the RP2350 vendor SVD: the DesignWare IC_CON register resets to 0x00000065, with IC_RESTART_EN (bit 5) set, and embassy-rp does not write that register.

Validation errors report the exact zero-based operation index in I2cBatchError.failed_op. A bus failure applies to the atomic transaction as a whole and cannot be attributed to one operation, so it reports failed_op = 0.

UART framing contract

The UART framing enums are wire-visible and serialized by variant index. Their deployed order in crates/pico-de-gallo-internal/src/lib.rs is:

TypeIndexVariantMeaning
UartDataBits0FiveFive data bits
UartDataBits1SixSix data bits
UartDataBits2SevenSeven data bits
UartDataBits3EightEight data bits; power-on default
UartParity0NoneNo parity bit; firmware default
UartParity1OddOdd parity
UartParity2EvenEven parity
UartParity3MarkParity bit always 1
UartParity4SpaceParity bit always 0
UartStopBits0OneOne stop bit; firmware default
UartStopBits1TwoTwo stop bits

UartStopBits deliberately does not use Zephyr’s numbering. Zephyr assigns UART_CFG_STOP_BITS_0_5 = 0, _1 = 1, _1_5 = 2, and _2 = 3. The RP2350 PL011 exposes only the STP2 bit, so 0.5 and 1.5 stop bits are unreachable. The wire enum therefore uses the dense One = 0, Two = 1 encoding instead of preserving two permanently unrepresentable holes. Consumers must map these values explicitly; changing their order to match Zephyr would be a wire break.

The configuration endpoints carry the complete framing tuple:

PathRequest typeResponse type
uart/set-configUartSetConfigurationRequestResult<(), UartError>
uart/get-config()Result<UartConfigurationInfo, UartError>

Both configuration structs contain the same fields, in the same order:

FieldType
baud_rateu32
data_bitsUartDataBits
parityUartParity
stop_bitsUartStopBits

uart/get-config reports the last successfully requested values from the firmware’s software shadow, not a register read-back. In particular, baud_rate is the requested rate rather than the divisor-rounded rate.

This wire-shape change did not move a package version. It rides the already pending, unreleased schema 0.8 change: crates/pico-de-gallo-internal/Cargo.toml currently declares version 0.8.0, and that crate’s build.rs derives SCHEMA_VERSION_MAJOR, SCHEMA_VERSION_MINOR, and SCHEMA_VERSION_PATCH as 0, 8, and 0. Firmware built from this tree therefore reports schema v0.8.0.

SPI chip-select contract

SpiError is serialized by variant index. The deployed indices are:

IndexVariantMeaning
0BufferTooLongArgument exceeds its directional payload ceiling
1OtherUnspecified firmware-reported SPI failure
2InvalidCsPinChip-select index outside 0..DeviceInfo::num_gpios
3CsPinUnavailableChip-select pin is explicitly configured as an input
4CsPinMonitoredChip-select pin is monitored for GPIO events

For spi/batch, the chip-select pin behaves as follows:

  • the pin is driven as an output for the duration of the batch,
  • on success it is left configured as an output, deasserted high; the prior direction is not restored,
  • on an execution failure after CS was asserted, it is likewise left deasserted high,
  • on a pre-validation failure or a refusal, the pin is left untouched,
  • pins explicitly configured as inputs are refused — firmware predating this contract may instead reconfigure them.

DeviceInfo::num_gpios is the runtime-authoritative pin count. Valid GPIO and SPI chip-select indices are 0..num_gpios; when it is zero, no index is valid. It supersedes the compile-time NUM_GPIOS default and must never be synthesized or defaulted when device/info decoding fails.

Build identity

DeviceInfo::build_id carries the firmware’s git describe --always --dirty --tags --match firmware-v* output, captured when the firmware was built, or "unknown" when git was unavailable. A trailing -dirty means the image was built from a modified working tree.

It is informational only and never a compatibility gate. validate() deliberately ignores it.

The two fields answer different questions, and conflating them would be a mistake in both directions:

QuestionField
Can we talk?schema_major / schema_minor
Are you the build I think you are?build_id

The schema version is derived from pico-de-gallo-internal’s package version, so it moves when the wire types change. Firmware behaviour can change while the types do not — i2c/batch moved from per-operation START/STOP to a single transaction inside an unchanged schema 0.7 — and bumping the schema version for such a change would falsely signal a wire-format break. build_id covers that gap without disturbing the compatibility axis.

The field is a heapless::String<BUILD_ID_CAPACITY> (64 bytes), encoded by postcard as a plain varint-length string, so an empty value costs one byte. Decoding an over-long string fails rather than truncating; the firmware build script truncates.

Schema versioning

The schema version constants are not handwritten. pico-de-gallo-internal generates SCHEMA_VERSION_MAJOR, SCHEMA_VERSION_MINOR, and SCHEMA_VERSION_PATCH in build.rs from the crate’s [package].version.

Caution

Do not edit SCHEMA_VERSION_* constants directly. Bump the pico-de-gallo-internal crate version and let build.rs regenerate them.

Before 1.0, the minor version is the breaking axis. After 1.0, that role moves to the major version.

That means a pre-1.0 bump is required when you:

  • add or remove an endpoint or topic,
  • change a request or response type,
  • append a new wire enum variant.

Host/firmware compatibility checks

The host library exposes PicoDeGallo::validate(). It calls device/info, reads the firmware’s schema version, and rejects mismatches early.

If validation fails, the host returns:

  • LegacyFirmware when the firmware is too old to support device/info, or
  • SchemaMismatch when the host and firmware disagree on the schema version.

This turns an otherwise confusing runtime failure into an explicit compatibility error.

Validation compares reported version numbers. It is only as trustworthy as the discipline that keeps those numbers honest: a schema bump is what makes a shape change visible, which is why appending even a single wire enum variant requires one.

Warning

Call validate() before doing real work. A mismatched pair is not guaranteed to fail fast on its own: postcard-rpc response keys include the response schema, so a peer that disagrees about a response type may wait indefinitely rather than return an error.

Lockstep releases for protocol changes

A wire change is never just one crate. Per the project rules, the same release cycle must update:

  1. pico-de-gallo-internal,
  2. pico-de-gallo-firmware,
  3. pico-de-gallo-lib,
  4. pico-de-gallo-hal,
  5. pico-de-gallo-ffi,
  6. pico-de-gallo-app,
  7. pico-de-gallo-mcp,
  8. pyco-de-gallo.

Important

Nothing automated knows that the protocol crate and firmware are wire-coupled. Lockstep is enforced by contributors, not by tooling.

For contributor policy and the full compatibility rules, see AGENTS.md.

The Firmware

The Pico de Gallo firmware lives in its own Cargo workspace at crates/pico-de-gallo-firmware/. That separation is intentional: it targets thumbv8m.main-none-eabihf, is no_std, and carries its own committed Cargo.lock.

Runtime model

The firmware runs on the RP2350 using Embassy:

  • embassy-executor for async task scheduling,
  • embassy-rp for RP2350 peripherals,
  • embassy-usb for the USB device stack.

postcard-rpc sits on top of that USB transport and dispatches endpoint handlers into async peripheral code. Requests are serialized on a shared context, while background tasks handle work such as GPIO event publication.

Tip

This is why the firmware can do DMA-backed transfers and interrupt-driven I/O without turning into a hand-written state machine maze.

Watchdog

The watchdog_supervisor_task arms the RP2350 hardware watchdog at 2 seconds, but feeds it only while dispatch and transmit progress remain plausible. Two independent slots track that progress:

  • The dispatch slot arms after receive() returns a frame and disarms when postcard-rpc goes back to waiting. Most handlers receive the 10-second default budget. Slow handlers declare a longer bound; caller-supplied bounds are capped at 30 minutes and receive 30 seconds of reply-serialization slack.
  • The TX slot arms while any WireTx sender is outstanding. Its 60-second budget covers TX-mutex starvation and GPIO event publishers that run outside the dispatcher.

The supervisor polls every 250 ms. The designed reset-latency bound is the active slot’s budget plus one poll period; this is a design target, not a board-attached measurement. On expiry it logs the slot and frame breadcrumb, writes them to watchdog scratch registers, and triggers a forced reset. Reset drops USB and loses every GPIO subscription. On the next boot, RTT reports the breadcrumb and clears it.

pause_on_debug(true) stops the hardware watchdog while the core is halted, but Embassy time continues. A supervisor wake gap above 500 ms is therefore treated as a debugger or scheduling discontinuity: live deadlines are shifted by the gap instead of resetting immediately. This rule’s board-attached acceptance check is still pending.

The supervisor is a backstop, not complete hang detection:

  • A wedge inside receive() itself is indistinguishable from legitimate idle and is not covered.
  • The TX slot has no hardware acceptance trigger; its correctness rests on inspection.
  • The single TX slot measures aggregate TX progress, not per-sender progress. One permanently starved sender remains masked while another sender completes at least once per 60-second TX_BUDGET.

The wedge-test feature provides a build-only acceptance hatch by disabling the firmware’s zero-length I2C write guard. It cannot be reached through the normal Rust-library or MCP i2c_write routes because both reject an empty payload host-side. Reproducing the wedge therefore also requires temporarily removing one of those host guards locally, as an uncommitted test mutation; the guards remain correct production behaviour.

The watchdog is the same on both hw-rev1 and hw-rev2 (no rev-specific code). The firmware crate has no unit-test harness; none is implied by the pure supervision policy function.

Build identity

crates/pico-de-gallo-firmware/build.rs runs

git describe --always --dirty --tags --match firmware-v*

and emits the result as a BUILD_ID constant, which main() logs at boot over defmt and the device/info handler returns.

Every flag matters, and each has a silent failure mode:

FlagRemoving it causes
--tagsThe firmware-v* tags are a mix of annotated and lightweight. Without this, git describe sees only annotated tags and resolves hundreds of commits too far back.
--match firmware-v*Describe picks the nearest tag in any namespace, returning an application-v* description.
--alwaysDescribe fails outright when no matching tag is reachable, e.g. in a shallow CI clone.
--dirtyThe locally-modified marker is lost — the single most useful part for a developer mid-bisect.

When git is unavailable, exits non-zero, or there is no repository, the script emits "unknown" and a cargo:warning. The build still succeeds, so a source tarball remains buildable. Values longer than 64 bytes are truncated on a UTF-8 character boundary.

The script re-runs on every build, by design. The pre-existing rerun-if-changed=memory.x narrows re-runs to a single file, so without an unconditional trigger the embedded identity would go stale across incremental builds and keep reporting a clean tree for a dirty one. A stale identity is worse than none, because it confirms a wrong conclusion rather than merely failing to prevent one.

Release workflows must fetch tags (fetch-depth: 0), or git describe degrades to a bare commit hash.

USB descriptors

The device enumerates as VID 045e / PID 067d with bDeviceClass = 0xFF (vendor-specific), so no OS class driver claims it. embassy_usb::Config defaults bcdUSB to 0x0210, which is what makes hosts request the BOS descriptor at all.

Interfaces

#ClassEndpointsPurpose
00xFFbulk IN + bulk OUTThe postcard-rpc transport
10xFFnoneCarries the WebUSB capability only

Interface 0 must stay first. The two interfaces share an identical class/subclass/protocol triple, so ordering is the only thing that distinguishes them. On Linux and macOS the host claims the first class-0xFF interface it finds; on Windows it claims interface 0 outright, because interfaces cannot be enumerated through WinUSB. Interface 1 exists because WebUsb::configure() cannot append a capability to an existing interface — bos_capability() is only reachable through an InterfaceAltBuilder, and embassy-usb exposes no public Builder::bos_writer().

BOS capabilities

CapabilityBytesWritten by
BOS header5embassy-usb, automatically
USB_2_0_EXTENSION7embassy-usb, automatically
PLATFORM — Microsoft OS 2.028postcard-rpc
PLATFORM — WebUSB24firmware, via WebUsb::configure()

Listed by size, not by wire order: the WebUSB capability is appended during builder setup, while the Microsoft OS 2.0 one is written later, inside Builder::build().

Those sizes are as of embassy-usb 0.5.1 and postcard-rpc 0.12.1. That is 64 bytes of the 256-byte BOS buffer sized by the WireStorage type alias in main.rs. Overflowing that buffer, or the configuration descriptor buffer, is a "Descriptor buffer full" panic raised at the point the descriptor is written.

The MS OS 2.0 capability is what makes Windows bind WinUSB without an INF file. The WebUSB capability advertises the landing page described in USB & OS Notes.

Vendor codes

Both platform capabilities reserve a bRequest value for vendor-specific control transfers on the device:

DescriptorVendor code
Microsoft OS 2.00x00 (set by postcard-rpc)
WebUSB0x01 (WEBUSB_VENDOR_CODE in main.rs)

They are kept distinct deliberately, though not out of strict necessity. embassy-usb answers a vendor request as an MS OS 2.0 descriptor request only when the bRequest matches and wIndex is 7; WebUSB’s GET_URL uses wIndex 2, so the two would not actually collide even if they shared a code. Using separate codes avoids relying on that disambiguation.

no_std and logging

This crate is no_std. Logging uses defmt over RTT.

Important

There is no println! fallback in firmware. If you need diagnostics, use defmt.

Hardware revisions

Two feature flags select the board revision:

FeatureDefaultBoardCapabilities
hw-rev1no — deprecatedv1.0I2C, SPI, GPIO, PWM
hw-rev2yesv1.1+I2C, SPI, UART, GPIO, PWM, ADC, 1-Wire

On hw-rev1, unsupported peripherals return Unsupported instead of touching unrouted hardware.

hw-rev1 is deprecated: it is no longer the default, build.rs emits a cargo:warning and main() logs a defmt::warn! at boot when it is enabled, and it will not be removed before 2031-09-01. It stays fully supported and built in CI until then. See Revisions: v1.0 vs v1.1 for the user-facing policy.

Build the two variants exactly as CI does — note that the default (no-flags) build is now hw-rev2:

cd crates/pico-de-gallo-firmware

# hw-rev2 (default)
cargo fmt --check
cargo clippy --target thumbv8m.main-none-eabihf -- -D warnings
cargo build --release --locked --target thumbv8m.main-none-eabihf

# hw-rev1 (deprecated — must opt in explicitly)
cargo clippy --target thumbv8m.main-none-eabihf \
    --no-default-features --features hw-rev1 -- -D warnings
cargo build --release --locked --target thumbv8m.main-none-eabihf \
    --no-default-features --features hw-rev1

Peripheral notes

The RP2350 pin map matches the hardware docs in Pinout & Connector:

  • I2C uses I2C1 on GPIO 2/3 and runs asynchronously with Embassy.
  • SPI uses SPI0 on GPIO 4/6/7 and supports DMA-backed full-duplex transfers.
  • UART uses UART0 on GPIO 0/1 with buffered, interrupt-driven I/O.
  • GPIO user pins are GPIO 8-11, with wait and subscribe support.
  • PWM outputs are GPIO 12-15 on slices 6 and 7.
  • 1-Wire uses PIO0 state machine 0 on GPIO 16.
  • ADC reads are single-shot samples on GPIO 26-29 in firmware, with board routing exposing ADC0-2 on current hardware.

UART reads, framing, and supervision

uart/read treats timeout_ms == 0 as a true single poll. The handler uses embassy_futures::poll_once on AsyncRead::read() and returns an empty result when that one poll is pending; it does not wait through a nominal one-millisecond timer. This matters to polling clients such as Zephyr’s uart_poll_in(), which otherwise pay a timeout on every idle check. It also deliberately reaches Embassy’s read path rather than consulting only ReadReady: polling the read is what consumes a latched receive error and re-enables RX interrupts.

Runtime framing is applied directly to UART0’s UARTLCR_H register because embassy-rp exposes no runtime framing setter. apply_framing() maps word length to WLEN, enables parity with PEN, selects even or space parity with EPS, selects mark/space stick parity with SPS, and selects two stop bits with STP2. The function follows embassy-rp’s disable, delay, register-write, and restore sequence.

Baud and framing are not applied atomically. uart/set-config calls set_baudrate() first, and that function restores an enabled UARTCR before apply_framing() runs. The pins therefore have a brief window with the new divisor and the old framing. The handler drains neither direction; callers must quiesce transmit and receive traffic before reconfiguration.

uart/get-config returns Context::uart_config, a software shadow updated after the two configuration steps. It is not a register read-back and therefore is never evidence of what the hardware is actually doing. In particular, it reports the requested baud rate rather than the line rate achieved after divisor rounding.

Both uart/write and uart/flush declare the fixed 60-second UART_TX_BUDGET. The ordinary dispatch budget is too short for the 1024-byte TX ring to drain at low baud rates; the explicit budget keeps a legitimate slow transmit from being mistaken for a wedged handler.

I²C batch transaction handling

i2c/batch uses two decode passes. The first validates the operation count, encoding, declared count, total read size, and every write payload before the bus is accessed. A malformed operation or zero-length write therefore reports the exact offending index without putting a START on the bus.

After validation, the handler borrows the I2C peripheral and shared scratch buffer as disjoint fields. Its second pass materialises the decoded list into a heapless::Vec<Operation, MAX_BATCH_OPS>, carving non-overlapping read slices from the scratch buffer while write operations borrow their request data.

The handler then makes one embedded_hal_async::i2c::I2c::transaction() call for the entire list. Adjacent same-direction operations have no intervening STOP or repeated START, a direction change re-addresses under a documented repeated START, and only the last operation carries a STOP. The repeated START is documented by the RP2350 vendor SVD: IC_CON resets to 0x00000065 with IC_RESTART_EN (bit 5) set, and embassy-rp does not write that register.

Because a bus error is returned for the atomic transaction as a whole, firmware reports it with failed_op = 0. Pre-bus validation errors retain their exact zero-based operation index.

SPI chip-select arbitration

spi/batch validates the operation count, encoding, declared count, and total read size before inspecting its chip-select pin. These checks therefore report their batch error before any chip-select error. Once the batch is valid, firmware refuses a chip-select index outside 0..DeviceInfo::num_gpios, a pin owned by a GPIO event monitor, or a pin explicitly configured as an input. These refusals occur before chip-select is driven: an invalid index does not touch any pin, and the other refusals leave the selected pin’s direction, level, and pull unchanged.

A LegacyAuto pin or an explicitly configured output is accepted. Firmware configures the pin as an output, drives it high, asserts it low for the batch, and deasserts it high after execution even when an SPI operation fails. It does not restore the prior direction or level and does not update the tracked GPIO mode.

Changing an accepted pin to output does not alter its configured pull: firmware changes only the SIO output-enable state, while the pull-up and pull-down settings remain in the separate pad-control state.

The shared transfer buffer is 4096 bytes (MAX_TRANSFER_SIZE). Firmware i2c/write and spi/write reject larger payloads before bus access; before issue #158 those two handlers had no upper bound. Data returned to the host is separately bounded by MAX_RESPONSE_PAYLOAD (1014 bytes), including the aggregate returned by a batch. That tighter value is derived from the host transport and cannot be observed by firmware, but firmware enforces it where necessary to avoid executing bus side effects whose result cannot be delivered. Host surfaces enforce both directional bounds before transmitting.

Dependency pins that matter

The firmware intentionally pins embassy-usb-driver = "=0.2.0". That exact version is documented in AGENTS.md because 0.2.1 pulled in an incompatible embedded-io-async update for the current embassy-usb 0.5 stack.

That documentation is part of the contributor contract: exact pins are not supposed to look mysterious.

Flashing

Flashing is the normal Pico UF2 flow:

  1. Hold BOOTSEL while connecting USB.
  2. Wait for the RP2350 mass-storage device to appear.
  3. Drag and drop the firmware .uf2.
  4. The board auto-resets and reconnects with the new firmware.

After flashing, gallo version is the quickest sanity check because it shows firmware version, schema version, hardware revision, and capabilities.

Releases & Compatibility

Pico de Gallo releases are cut by hand, and compatibility depends on humans understanding which pieces move together.

Tag prefixes

Each published surface has its own release tag prefix:

ComponentTag
pico-de-gallo-internalinternal-v*
pico-de-gallo-liblibrary-v*
pico-de-gallo-halhal-v*
pico-de-gallo-ffiffi-v*
gallo CLIapplication-v*
gallo-mcpmcp-v*
pyco-de-gallopyco-v*
pico-de-gallo-firmwarefirmware-v*
hardware artifactshardware-v*

What drives a release?

Releases are manual. A maintainer bumps each crate’s [package].version, updates the cross-crate dependency specs and each changed crate’s crates/<crate>/CHANGELOG.md, merges to main, and then pushes one tag per component. The tag-triggered release-*.yml workflows publish to crates.io / PyPI and build the binary artifacts.

There is no root changelog. Every crate owns its own, and the Zephyr module uses zephyr/CHANGELOG.md.

Contributors still land Conventional Commits with crate scopes such as feat(internal): ... or fix(firmware): ... — that scoped history is what a maintainer reads when hand-writing those changelogs and deciding which crates to bump.

Tip

The scope is not decoration. It tells the release author what changed in each crate and where the version bump belongs.

Protocol changes are lockstep changes

When the wire protocol changes, compatibility is broader than one crate tag. The protocol crate, firmware, and every host-facing crate must move in the same release cycle.

That means coordinating:

  • pico-de-gallo-internal,
  • pico-de-gallo-firmware,
  • pico-de-gallo-lib,
  • pico-de-gallo-hal,
  • pico-de-gallo-ffi,
  • pico-de-gallo-app,
  • pico-de-gallo-mcp,
  • pyco-de-gallo.

Important

Nothing enforces wire coupling for you. If a protocol change lands without its matching host and firmware version bumps, users will feel it.

Unreleased schema 0.8 changes

Schema 0.8 is still in flight: internal-v0.7.0 is the latest published protocol tag. Until 0.8 is released, build the host and firmware from the same tree rather than treating two builds that both report 0.8.0 as interchangeable.

DeviceInfo changed, so 0.7 pairs fail opaquely

Schema 0.8 appended build_id to DeviceInfo. That is an append in the encoding, but DeviceInfo is not an ordinary wire type: postcard-rpc derives each endpoint’s key from its response type’s schema, so changing it re-keyed device/info itself.

The consequence is that a schema 0.8 host and schema 0.7 firmware — or the reverse — cannot diagnose each other. The peer replies under the other key, the dispatcher drops the unmatched frame, and validate() returns Timeout rather than SchemaMismatch. The schema numbers that would have named the problem are inside the message that was dropped, and no version bump can surface them, because the version is payload rather than key.

So this particular boundary does not produce the normal, self-describing failure documented below. It looks exactly like an unresponsive board. If gallo hangs or times out against a board you believe is healthy, suspect a 0.7/0.8 skew before you suspect the hardware.

gallo version still works across the pair and is the tool to reach for: it reads the version endpoint, whose VersionInfo schema is deliberately held stable precisely so one diagnostic survives a device/info re-key.

The fix is the usual one — build both sides from the same release. Schema 0.7 and 0.8 firmware and host components must not be mixed.

UART configuration gained framing

The same unreleased schema 0.8 development cycle expanded both UartSetConfigurationRequest and UartConfigurationInfo from a baud rate alone to four fields: baud rate, data bits, parity, and stop bits. It also added three wire enums, in this exact source order:

  • UartDataBits: Five, Six, Seven, Eight;
  • UartParity: None, Odd, Even, Mark, Space;
  • UartStopBits: One, Two.

That order is ABI. Postcard serializes enum variants by index, so reordering an existing variant silently changes the wire protocol even though the Rust enum still compiles.

Because these request and response shapes changed while 0.8.0 remains unreleased, a host and firmware built from different commits of this in-flight schema can disagree while both report version 0.8.0. Build both sides from the same tree until schema 0.8.0 is released. The shared version number alone cannot distinguish an earlier baud-only 0.8 build from a later framing-aware one.

How users check compatibility

There are two main compatibility checks:

  • gallo version prints firmware version, schema version, hardware revision, and capabilities.
  • PicoDeGallo::validate() checks compatibility programmatically and fails with SchemaMismatch or LegacyFirmware when the pair should not talk.

For most users, gallo version is the first stop. For library users, validate() is the guardrail you call before doing real work.

“I flashed new firmware and now my host is broken”

That usually means the firmware and host were built against different versions of pico-de-gallo-internal.

Typical symptoms include:

  • validate() returning SchemaMismatch,
  • a new firmware exposing endpoints an older host does not know about,
  • older firmware lacking device/info, which shows up as LegacyFirmware.

The fix is simple: upgrade the matching host component for the firmware you flashed, or downgrade the firmware to the host release you are using.

Warning. The protocol is typed, but a mismatched pair is not guaranteed to fail fast. Because postcard-rpc response keys include the response schema, a mismatched peer may wait indefinitely. See the wire-protocol warning.

MSRV and release hygiene

The workspace tracks Rust 1.90 as its MSRV, and CI checks it explicitly. That includes the host workspace and the firmware workspace.

For contributor-only release details, including the full manual-release checklist, see AGENTS.md and the repository’s RELEASE.md.

Status Code Reference

Every gallo_* FFI function (except the lifecycle calls gallo_init, gallo_init_with_serial_number, and gallo_free) returns a Status value. For C11/C17, cbindgen emits typedef int32_t Status together with integer constants, rather than making Status a true C enum type. To catch appended statuses at compile time, cast the scrutinee (switch ((enum Status)x)), omit any default: label inside the switch, and build with -Werror=switch. A post-switch fallback for an unknown numeric value is intentional and correct.

  • Ok (0) — success.
  • Negative values — errors, grouped roughly by peripheral.

Stability

Important

Status code numeric values are part of the C ABI and never change once shipped. Existing codes are never renumbered or reused; new codes are only appended at the bottom of the enum.

If you compile against a header from an older release, codes you don’t recognize will be values your code has never seen. Treat unknown negative values as “some error” — never assume a number that doesn’t appear in your header means success.

Complete Status Table

NameValueDescription
Ok0Operation successful
I2cReadFailed−1I²C read failed
I2cWriteFailed−2I²C write failed
InvalidResponse−3Firmware produced an invalid response
Uninitialized−4Library was not initialised (NULL context)
InvalidArgument−5Caller passed an invalid argument
PingFailed−6Ping round-trip failed
SpiReadFailed−7SPI read failed
SpiWriteFailed−8SPI write failed
SpiFlushFailed−9SPI flush failed
GpioGetFailed−10GPIO get failed
GpioPutFailed−11GPIO put failed
GpioWaitFailed−12GPIO wait failed
SetConfigFailed−13Set config failed (legacy)
VersionFailed−14Version query failed
I2cWriteReadFailed−15I²C write-read failed
I2cSetConfigFailed−16I²C set config failed
SpiSetConfigFailed−17SPI set config failed
I2cNack−18I²C target did not acknowledge
I2cBusError−19I²C bus error
I2cArbitrationLoss−20I²C arbitration loss
I2cOverrun−21I²C data overrun
BufferTooLong−22Argument exceeds its directional payload ceiling
I2cAddressOutOfRange−23I²C address out of valid range
GpioInvalidPin−24Invalid GPIO pin number
CommsFailed−25USB communication failure
I2cScanFailed−26I²C bus scan failed
GpioSetConfigFailed−27GPIO set config failed
GpioWrongDirection−28GPIO pin direction mismatch
I2cGetConfigFailed−29I²C get config failed
SpiGetConfigFailed−30SPI get config failed
UartReadFailed−31UART read failed
UartWriteFailed−32UART write failed
UartFlushFailed−33UART flush failed
UartOverrun−34UART receiver overrun
UartBreak−35UART break condition
UartParity−36UART parity error
UartFraming−37UART framing error
UartInvalidBaudRate−38Invalid baud rate
UartSetConfigFailed−39UART set config failed
UartGetConfigFailed−40UART get config failed
PwmSetDutyCycleFailed−41PWM set duty cycle failed
PwmGetDutyCycleFailed−42PWM get duty cycle failed
PwmEnableFailed−43PWM enable failed
PwmDisableFailed−44PWM disable failed
PwmSetConfigFailed−45PWM set config failed
PwmGetConfigFailed−46PWM get config failed
PwmInvalidChannel−47Invalid PWM channel
PwmInvalidDutyCycle−48Invalid PWM duty cycle
PwmInvalidConfiguration−49Invalid PWM configuration
AdcReadFailed−50ADC read failed
AdcGetConfigFailed−51ADC get config failed
AdcConversionFailed−52ADC conversion error
GpioPinMonitored−53Pin is currently subscribed
GpioPinNotMonitored−54Pin is not subscribed
GpioSubscribeFailed−55GPIO subscribe failed
GpioUnsubscribeFailed−56GPIO unsubscribe failed
OneWireNoPresence−571-Wire: no device responded to reset
OneWireBusError−581-Wire: bus communication error
OneWireReadFailed−591-Wire: read failed
OneWireWriteFailed−601-Wire: write failed
OneWireSearchFailed−611-Wire: ROM search failed
DeviceInfoFailed−62Device info query failed
SchemaMismatch−63Schema version mismatch between host and firmware
LegacyFirmware−64Firmware too old to support device/info
Unsupported−65Peripheral not available on this hardware revision
I2cBatchFailed−66I2C batch transaction failed
SpiBatchFailed−67SPI batch transaction failed
SpiTransferFailed−68SPI full-duplex transfer failed
SystemResetSubscriptionsFailed−69system/reset-subscriptions call failed
GpioTimeout−70GPIO wait timed out
SpiInvalidCsPin−71SPI chip-select index is outside the reported GPIO range
SpiCsPinUnavailable−72SPI chip-select pin is configured as an input
SpiCsPinMonitored−73SPI chip-select pin is monitored for GPIO events
SpiNoGpios−74Device reports zero GPIOs, so no chip-select pin exists
DeviceInfoTimeout−75device/info did not respond within 300 seconds
CallTimeout−76Device did not answer an RPC within its bound

CallTimeout (−76) means the request was transmitted but no reply arrived. It is deliberately distinct from CommsFailed (−25): the transport is healthy and the device very likely is too, so it is not a link failure. It is also distinct from DeviceInfoTimeout (−75), which covers only the validated metadata fetch and has a far longer bound. The handle stays usable, so the call may simply be retried — except after a batch, whose fate is unknown (it may have executed fully, partially, or not at all), so a blind retry there can repeat side effects.

BufferTooLong (−22) is also a host-side refusal. Data returned by the device is limited to GALLO_MAX_RESPONSE_PAYLOAD (1014 bytes), while data sent to it is limited to GALLO_MAX_TRANSFER_SIZE (4096 bytes). Full-duplex SPI transfer uses the tighter response ceiling. A batch is bounded a third way, by GALLO_MAX_REQUEST_FRAME (5119 bytes) on its whole request frame — the only bound on its aggregate outgoing bytes. The same status still represents a device-side buffer refusal, but an over-ceiling argument is rejected locally before transmission. Issues #158, #179 and #186 added no new Status value.

SpiInvalidCsPin (−71) and SpiNoGpios (−74) are host-side refusals: the chip-select was rejected before anything was transmitted, and no pin was driven. DeviceInfoFailed (−62), SchemaMismatch (−63), LegacyFirmware (−64) and DeviceInfoTimeout (−75) mean the host could not establish the device-reported GPIO count at all. Those four are never reported as a chip-select error: a failure to learn the valid range is not a complaint about the caller’s pin.

Source of Truth

The enum lives in crates/pico-de-gallo-ffi/src/lib.rs and is mirrored into the generated pico_de_gallo.h by cbindgen. If a code is missing from this table after a release, file an issue — that’s a documentation bug.

Endpoint Catalog

This is the canonical list of postcard-rpc endpoints and topics exposed by the Pico de Gallo firmware. The source of truth is the endpoints! and topics! invocations in crates/pico-de-gallo-internal/src/lib.rs.

Important

Variant order in shared enums is part of the wire ABI. postcard encodes enum variants by their index (0, 1, 2…), not by the discriminant value. Reordering or removing a variant is a breaking schema change. New variants must be appended at the end. See AGENTS.md §6 for the full rule.

Each schema break bumps SCHEMA_VERSION_MINOR (pre-1.0). The host library refuses to talk to a firmware whose major/minor version doesn’‘t match — that’’s where Status::SchemaMismatch (−63) comes from.

Endpoints

PathDescription
pingEcho a u32. Useful for liveness checks.
versionFirmware version triple (major / minor / patch).
device/infoFirmware version + schema version + capability bitfield + runtime GPIO count + firmware build identity.
i2c/readRead N bytes from a target address.
i2c/writeWrite bytes to a target address.
i2c/write-readWrite then read on the same target (repeated start).
i2c/scanProbe every address on the bus.
i2c/batchOne I²C transaction: gather same-direction ops, repeated START on direction changes, one final STOP. See the contract below.
i2c/set-configSet I²C frequency (I2cFrequency enum).
i2c/get-configQuery current I²C frequency.
spi/readClock in N bytes (MISO).
spi/writeClock out bytes (MOSI).
spi/transferFull-duplex transfer of equal-length TX and RX.
spi/flushWait for any in-flight DMA SPI ops to complete.
spi/batchSequence of SPI ops under chip-select in one round-trip. See the chip-select contract below.
spi/set-configSet frequency, CPHA, and CPOL.
spi/get-configQuery current SPI configuration.
uart/readRead with timeout.
uart/writeWrite bytes.
uart/flushDrain the TX FIFO.
uart/set-configSet baud rate and framing (data bits, parity, stop bits).
uart/get-configQuery current UART baud rate and framing.
gpio/getRead a pin.
gpio/putWrite a pin.
gpio/wait-highBlock until pin is high.
gpio/wait-lowBlock until pin is low.
gpio/wait-risingBlock until rising edge.
gpio/wait-fallingBlock until falling edge.
gpio/wait-anyBlock until any edge.
gpio/set-configSet direction and pull resistor.
gpio/subscribeBegin push-based edge events on a pin.
gpio/unsubscribeStop push-based events on a pin.
pwm/set-duty-cycleSet raw compare value.
pwm/get-duty-cycleQuery current compare value and max.
pwm/enableEnable the PWM slice owning a channel.
pwm/disableDisable the PWM slice owning a channel.
pwm/set-configSet frequency and phase-correct mode.
pwm/get-configQuery PWM configuration.
adc/readSingle-shot ADC read.
adc/get-configQuery ADC capabilities (channel count, resolution).
onewire/reset1-Wire reset + presence detection.
onewire/readRead N bytes from the 1-Wire bus.
onewire/writeWrite bytes on the 1-Wire bus.
onewire/write-pullupWrite bytes then assert strong pullup (parasitic power).
onewire/searchStart a ROM search (returns first ROM).
onewire/search-nextContinue a ROM search.
system/reset-subscriptionsTear down any GPIO subscriptions left over from a prior host.

i2c/batch transaction contract

Firmware schema 0.7 and newer executes the entire batch as one I²C transaction. A START and address precede the first operation. Adjacent operations in the same direction run back to back without a STOP or repeated START, so two adjacent writes form one gather write. A direction change emits a repeated START and re-addresses the target. Only the final operation is followed by a STOP. Zero-length writes are rejected.

spi/batch chip-select contract

A valid cs_pin is inside 0..DeviceInfo::num_gpios, is not being monitored for GPIO events, and is not explicitly configured as an input. Refusals return InvalidCsPin, CsPinMonitored, and CsPinUnavailable respectively, and leave the pin unchanged. An accepted pin is driven as an output and left deasserted high.

Topics (server → client push)

PathMessageDescription
gpio/eventGpioEventPush stream of edge events for subscribed GPIO pins.

The gpio/event topic is a single multiplexed stream carrying events for every subscribed pin (each GpioEvent carries a pin: u8 field so the host can demultiplex). gpio/subscribe(pin, edge) enables firmware-side monitoring of one pin; gpio/unsubscribe(pin) tears it down. Events for any subscribed pin arrive on the shared stream — there is no per-pin sub-channel and no implicit per-pin stream-open/close.

Subscriptions are server-side state that outlives the USB transport, so a host whose process crashed or was killed without unsubscribing will leave the pin owned by a firmware monitor task. Hosts should call system/reset-subscriptions once on connect (after device/info) to reclaim any such pins. The endpoint is idempotent and returns the number of subscriptions that were torn down.

Stale events: after gpio/unsubscribe(pin) returns Ok, a GpioEvent for that pin may still arrive (the firmware best-effort publishes events that were in-flight at unsubscribe time). Consumers should filter against their current subscription set and drop unknown-pin events without erroring.

Adding Endpoints

If you’‘re contributing a new endpoint, the recipe touches six crates plus tests and documentation. Don’’t forget the schema-version bump and the lockstep release of internal + firmware + every host crate.

Troubleshooting

Device Doesn’’t Show Up

gallo list finds nothing

  1. Confirm the LED on the Pico 2 is lit. If not, check the USB cable — many USB-C cables are power-only.
  2. Confirm the firmware is flashed. Hold BOOTSEL while plugging in. If the board mounts as a RPI-RP2 mass-storage device, the firmware is not running — see Assembly & Flashing.
  3. Linux: install the udev rule from USB & OS Notes. Without it, nusb can’’t claim the interface as a regular user.
  4. Windows: install the WinUSB driver via Zadig, selecting interface 0. The default Windows USB driver does not expose vendor-specific endpoints to user space, and Pico de Gallo’s second interface exists only for WebUSB descriptors.
  5. Check dmesg (Linux), Device Manager (Windows), or system_profiler SPUSBDataType (macOS) for VID 045E and PID 067D.

gallo version fails with a comms error

A successful gallo list followed by a failing gallo version usually means another process has the device open — typically a previous gallo instance that didn’’t exit cleanly, or a Python script holding a PycoDeGallo. Close it.

SchemaMismatch (status code −63)

The host library was built against a different SCHEMA_VERSION_MINOR than the running firmware. Re-flash the matching firmware release, or upgrade/downgrade the host crates to match. See Releases & Compatibility.

LegacyFirmware (status code −64)

The firmware is too old to answer device/info. Re-flash a recent firmware build.

DeviceInfoTimeout (status code −75)

device/info produced no answer within the 300-second bound. The obvious cause is a board that stopped responding, but it is not the only one, and the host cannot tell them apart.

postcard-rpc derives every endpoint’s key from the schema of its response type. Changing the shape of DeviceInfo — adding, removing, renaming, or retyping a field — therefore changes the device/info key. If the host and the firmware were built from different trees, the firmware answers under the other side’s key, the receiving dispatcher finds no match, and the frame is dropped without ever being decoded. No error is raised, because nothing failed; the call simply never completes. Retrying or replugging cannot help, since the mismatch is fixed at compile time.

If you are running a development build of either side, rebuild both from the same tree. gallo version still works in this situation: it bounds device/info at five seconds and then falls back to the legacy version endpoint, whose response type is unchanged and therefore still matches.

The board reset itself

The firmware’s dispatch supervisor deliberately resets the board when an in-flight request or transmit path exceeds its budget. USB disconnects and re-enumerates, and active GPIO subscriptions are lost.

Attach an RTT reader and look for these defmt messages:

supervisor: <slot> slot expired (key=<frame-key>) — resetting
previous boot ended in a supervisor-forced reset: slot=<slot> key=<frame-key>

dispatch identifies a request handler that did not complete within its default or declared budget. tx identifies absence of aggregate transmit completion. The hexadecimal key is the first four frame-header bytes retained as a breadcrumb; include it with the preceding request when reporting a bug.

If RTT instead reports previous boot ended in a watchdog reset not raised by the supervisor, the executor itself stopped making enough progress to feed the 2-second hardware watchdog. A cold boot has neither message.

Peripheral Errors

Unsupported (status code −65)

The peripheral exists in the protocol but isn’’t wired on this hardware revision. Confirm discovery with gallo list and firmware communication with gallo version, then see Revisions.

GpioTimeout (status code −70)

A bounded GPIO wait expired before the requested level or edge arrived. Check the wiring and increase the timeout only if the expected event is slow.

SpiInvalidCsPin (status code −71)

The chip-select index is outside 0..num_gpios. Query the connected device’s runtime GPIO count and choose an index below it.

SpiCsPinUnavailable (status code −72)

The selected chip-select pin is explicitly configured as an input. Configure it as an output, or select another user GPIO.

SpiCsPinMonitored (status code −73)

The selected chip-select pin has an active GPIO event subscription. Unsubscribe it before using the pin as chip-select.

SpiNoGpios (status code −74)

The device reports zero user GPIOs, so no firmware-managed SPI chip-select is available. Use a device or firmware build that exposes user GPIOs.

DeviceInfoTimeout (status code −75)

The device/info query did not complete within 300 seconds. Check the USB connection and peer compatibility before retrying.

I²C Nack (−18)

The target didn’’t acknowledge. Common causes:

  • Wrong address. gallo i2c scan confirms which addresses ACK.
  • Missing pull-ups. v1.1 boards have on-board 4.7 kΩ pull-ups; v1.0 does not.
  • Target powered off, or VCC level mismatch (Pico de Gallo runs at 3.3 V on the bus pads).

I²C BusError (−19) / ArbitrationLoss (−20)

Usually a wiring issue (long stub leads, no pull-ups, multi-master collisions). Try a slower clock with gallo i2c set-config --frequency standard.

BufferTooLong (−22)

The argument exceeds one of three independent ceilings. Choose the ceiling by the direction of the bytes and by whether the call is a batch, not by the endpoint:

DirectionConstantValueExamples
device → hostMAX_RESPONSE_PAYLOAD1014reads, the read half of I²C write-read, batch returning operations, all of full-duplex SPI transfer
host → deviceMAX_TRANSFER_SIZE4096writes and the write half of I²C write-read
host → device, whole frameMAX_REQUEST_FRAME5119a batch’s aggregate outgoing bytes, header and encoding included

MAX_RESPONSE_PAYLOAD is derived from postcard-rpc’s host inbound USB buffer: 1024 − 7 bytes of response header (1 discriminant, 2 key, 4 sequence) − 1 postcard Result discriminant − 2 bytes of varint length prefix = 1014. It is a host-transport property that firmware cannot observe. spi_transfer makes the distinction visible: it is full duplex, so its whole argument is limited to 1014, while send-only spi_write accepts 4096. Issue #158 measured spi/write succeeding at 1015 bytes exactly where spi/transfer fails.

MAX_REQUEST_FRAME applies only to i2c/batch and spi/batch, because every other endpoint is kept a kilobyte clear of it by MAX_TRANSFER_SIZE. It bounds the aggregate, so a batch of individually modest writes can trip it; and it does not cap an individual batch Write at 4096, because a batch write never enters the firmware’s scratch buffer. See Transaction Batching, and use i2c_batch_request_frame_len / spi_batch_request_frame_len rather than modelling the overhead by hand.

Every host surface now rejects an over-ceiling argument locally before transmitting. Rust and MCP report BufferTooLong; C returns Status::BufferTooLong; Python raises RuntimeError; Zephyr returns -EMSGSIZE. No new C Status value was added. To fix the call, split or chunk the transfer. A batch’s returning operations count in aggregate, so dividing a large read into operations inside one batch does not evade the response limit; the same is true of its outgoing operations and the frame limit.

The historical measurements explain why these guards exist. Before issue #179, a batch returning 1015–4096 bytes executed its bus operations and only then lost the response with Postcard(DeserializeUnexpectedEnd); #179 added the aggregate batch ceiling. Before #158, plain I²C reads showed the same 1014/1015 edge, while I²C write requests reached 4096 and initiated a transaction at an unpopulated address. That verifies request framing, not successful clocking of a 4096-byte payload, and no I²C hang found in that range proves none exists. Before #186 an over-ceiling batch frame produced no error at all — see the next section.

These figures come from the issue #158 measurements on board 5256657D8A5D7F03 and the issue #179 and #186 measurements on board 49742081C885AC69.

The older Zephyr SPI guidance capped transfers at 1013 and documented full duplex only through 512 because 1014 and the 513–1013 range had not been tested. Issue #158 superseded it: spi/transfer was measured at 1014 successfully, and 1015 now fails cleanly. PDG_SPI_MAX_BUFFER is therefore 1014 and is tied to GALLO_MAX_RESPONSE_PAYLOAD by a _Static_assert.

An earlier 1015-byte TX-only request did reproduce a device-wide dispatcher wedge. Issue #158 could not reproduce it on firmware 62dd64e710fd, after issue #157 added the watchdog supervisor and issue #178 bounded every host RPC: the call returned a clean error in 12 ms and the board stayed responsive. This is a non-reproduction on one firmware build, not proof the earlier observation never occurred.

If you are running firmware older than the #157 watchdog supervisor and do hit a wedge, the recovery recorded at the time was USB re-enumeration: usbipd detach followed by attach on Windows/WSL. That is an observed procedure, not proof that detaching cancels the blocked handler. On Linux and macOS, reconnect the cable or use USB unbind/rebind, and power-cycle if re-enumeration is unavailable or ineffective. system/reset-subscriptions cannot run while dispatch is blocked, so it is not a way out.

A large request times out instead of returning BufferTooLong

There is a third ceiling, and crossing it produces silence rather than an error. The whole request frame — postcard-rpc header plus encoded body — must fit in the firmware’s 5120-byte receive buffer, and the usable maximum is 5119 bytes. A longer frame is discarded by the server with no reply, so the call ends in Timeout (or, on firmware older than the #178 host-side bounds, hangs). The board stays fully responsive; this is not a wedge.

Since issue #186 no supported call should reach this. MAX_TRANSFER_SIZE keeps every single-argument call clear of it — 4096 bytes of payload plus a header and the request struct’s own encoding lands a little over 4110 bytes — and the batch endpoints, which were the one remaining route, are now bounded against MAX_REQUEST_FRAME host-side and report BufferTooLong instead. If you still see a timeout in this range, you are either on firmware and host built from different trees, or you have found a new route into the ceiling worth reporting.

Two details are worth knowing if you are measuring this yourself, because both have produced wrong answers before:

  • The usable payload is six bytes smaller until the connection has received its first reply. postcard-rpc’s HostClient starts with an 8-byte key in the request header and narrows to the server’s 2-byte key only after a reply arrives. A request that is itself dropped narrows nothing. This is also why the batch guard assumes the wide header and so refuses up to six bytes earlier than a warm connection strictly requires.
  • A probe that does not reproduce the real client’s header measures a different protocol. See Wire Protocol.

GPIO WrongDirection (−28)

You read from a pin configured as output (or vice versa). Call gpio_set_config first with the matching direction.

Firmware Build Issues

embassy-usb-driver 0.2.1 breaks the build

A known regression. Pin embassy-usb-driver = "=0.2.0" in the firmware Cargo.toml. See AGENTS.md §13.10 for the full story.

elf2uf2-rs 2.2.0 from crates.io is stale

The CI installs it from git because the crates.io version is missing the --family flag. Use picotool or install from git.

Driver Development

“It worked over USB but not on the real MCU”

A few things to check:

  • Timing. USB introduces ~1 ms round-trip latency. If your driver relies on tight inter-byte timing (some 1-Wire and WS2812-style protocols), the on-host loop will look fine while the on-target loop fails.
  • Clock speed. Pico de Gallo’‘s I²C / SPI clocks are configurable but discrete. Confirm the target MCU’’s HAL can produce the same speed.
  • Pull-ups and levels. Voltage-level mismatches show up as intermittent NACKs.

An I²C transaction() gather write misbehaves

Firmware with schema 0.7 or newer executes the complete operation list as one I²C transaction. Adjacent operations in the same direction run back to back without a STOP or repeated START, so adjacent writes form one gather write. A direction change emits a repeated START, and only the final operation is followed by a STOP. Zero-length writes are rejected.

Older firmware executed every operation as an independent transaction with its own START and STOP. If a gather write is split on the bus or the target interprets adjacent writes separately, check device/info and update to firmware reporting schema 0.7 or newer.

Where to Get Help