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

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_configRead 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_flushFlush 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_configRead 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_flushWait until UART TX has drained
uart_set_configbaud_rate, data_bits, parity, stop_bitsReplace the complete UART configuration
uart_get_configReturn 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
versionRead the firmware version
device_infoRead firmware version, schema version, HW revision, capabilities, runtime GPIO count, and build identity
validatePerform a strict schema compatibility check and return DeviceInfo
num_gpiosRead 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_configRead ADC capabilities and constants
onewire_resetReset 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_searchStart ROM search and return the first device
onewire_search_nextContinue 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.