---
title: "Library API"
space: "Node UIO"
url: "https://docs.modbee.org/node-uio/api"
updated: "2026-10-25"
---

# API

One class, `NodeUioAPI`, reached through `uio()`. There is one board, so there
is one instance.

```cpp
#include <NodeUioAPI.h>

void setup() {
  uio().begin();
}

void loop() {
  uio().update();
}
```

`begin()` brings the board up and is safe to call more than once. `update()`
reads the inputs, drives the outputs and services the converters; call it once
per cycle, before the code that uses the values. `ready()` is true once both
converters (the ADS1115 and the GP8413) answered at `begin()`.

Every call that can fail returns a `UioResult`. A channel that does not exist
gives `UIO_INVALID_ARGUMENT` rather than reading past the end of an array.
Channels are zero based throughout: channel 0 is DI01, DO01, AI01 or AO01.

| Result | Meaning |
| --- | --- |
| `UIO_OK` | Done. |
| `UIO_NOT_READY` | The board, or that part of it, has not come up yet, or the operation is still in progress. |
| `UIO_INVALID_ARGUMENT` | No such channel, or a value that cannot be used. |
| `UIO_IO_ERROR` | The hardware or the flash refused. |
| `UIO_NO_DATA` | Nothing stored. |
| `UIO_TOO_LARGE` | More than the hardware or the store can hold. |
| `UIO_UNSUPPORTED` | Not this platform. |

The headers include no Arduino headers, and off the board the calls answer
`UIO_UNSUPPORTED` (or false, or 0), so code that uses the library also builds
and links on a host.

## Digital

```cpp
bool on = false;
uio().digitalIn(0, on);
uio().setDigitalOut(0, on);
```

Inputs are sampled and outputs driven by `update()`, so a value read twice in
one cycle is the same value twice, and an output set now changes at the next
`update()`. There is no debounce and no inversion. `digitalOut()` reads back
the level last asked for.

### Pulse counting

```cpp
uio().pulseStart(0, 1000);     // count rising edges on DI01, ignore pulses under 1 us
int64_t pulses = 0;
uio().pulseCount(0, pulses);
```

The ESP32-S3's pulse counter (PCNT) counts rising edges in hardware, so no
pulse is missed however long the cycle is. Up to four inputs at a time, because
the chip has four counters; a fifth `pulseStart()` returns `UIO_TOO_LARGE`. The
second argument is the hardware glitch filter in nanoseconds, capped at 12000.
The count is cumulative from `pulseStart()`; `pulseStop()` frees the counter.
The input still reads normally through `digitalIn()`.

`NodeUioPulse.h` provides `UioPulseRate`, which turns the cumulative count into
pulses per second for flow meters and similar slow or fast sources.

## Analog

Values are floats in the unit the channel is set for: volts on `UIO_VOLTAGE`
(0 to 10) and milliamps on `UIO_CURRENT` (0 to 20). `uioFullScale()` gives the
top of a range.

```cpp
uio().setInputRange(0, UIO_CURRENT);
float milliamps = 0.0f;
uio().analogIn(0, milliamps);

uio().setOutputRange(0, UIO_VOLTAGE);
uio().setAnalogOut(0, 5.0f);   // 5 V
```

The range only decides the scaling. Whether a terminal physically measures or
drives voltage or current is set by a solder bridge on the board (see
[HARDWARE.md](/node-uio/hardware)), so a channel bridged for voltage but set to
`UIO_CURRENT` returns a confident wrong number. Both ranges default to
`UIO_VOLTAGE` in software, whatever the board is bridged for.

`analogInRaw()` and `analogOutRaw()` give the converter's own count, either
side of the scaling. Full scale is `UIO_RAW_FULL_SCALE` (32767).

An input whose count reaches `UIO_RAW_SATURATED` is beyond what the range can
measure, and reads `uioOverRange()`: 11 V or 22 mA. That is clearly over range,
so a transmitter that has failed high (NAMUR NE 43: above 21 mA) is seen as a
fault rather than as a plausible full-scale value.

The four inputs share one converter and are converted in turn, one conversion
per `update()` at most every 5 ms, so each input refreshes about every 20 ms
when `update()` runs at least that often. The output converter is written only
when the value changes.

### Calibration

Two points and an offset per channel, in a `UioCalibration`:

| Field | Meaning |
| --- | --- |
| `offset` | Added after scaling, in the channel's unit (V or mA). |
| `low` | The converter count at the bottom of the range. |
| `high` | The converter count at the top of the range. |

Leaving `low` and `high` equal falls back to a straight ratio against 32767.
The default is `{0, 0, 32767}`.

```cpp
UioCalibration cal = {0.0f, 120, 32500};
uio().setInputCalibration(0, cal);
uio().saveCalibration();
```

Values apply at once and live in RAM until `saveCalibration()` writes them to
NVS; `begin()` loads them back. `resetCalibration()` restores the defaults and
saves that. `uioScaleInput()` is the scaling itself, for code that needs to
convert a count the same way.

## HAT power

```cpp
uio().setHatPower(true);
```

Drives the HAT power enable on GPIO 9, immediately rather than at the next
`update()`. VOUT to the headers is fused and always live; each HAT switches its
own supply from it, and this is the line that tells it to. There is no sense
line back, so `hatPower()` reports what was asked for, not what the HAT did.

## The status LED

The board drives it: green once both converters answer, red when they do not,
written only when it changes. Nothing needs to call anything for that.

`setLed(red, green, blue)` takes it over. From then until the next restart the
health colour is no longer written and the LED belongs to the program.

## The button

```cpp
if (uio().takeShortPress()) uio().requestRadio();
```

The button beside the antenna, marked WiFi on the silkscreen and also brought
out to the HAT header. `button()` is true while held and `buttonHeldMs()` says
for how long. `takeShortPress()` and `takeLongPress()` each return true once
per press and then clear, so read them once per cycle and from one place.
`setButtonLongMs()` sets the threshold between short and long (2000 ms by
default) and `setButtonDebounceMs()` how long a press must last to count at all
(30 ms by default).

Edge detection is done in `update()` rather than by the caller, because a press
can be shorter than a cycle and there is only one button to consume presses
from.

## Network

```cpp
uio().connect("ssid", "password");
if (uio().connected()) { /* ... */ }
```

The radio is shared: a protocol library or the OpenPLC firmware may bring it up
first. `connect()` brings it up only when nothing else has; if a network is
already joined it returns `UIO_OK` and changes nothing. If this call brought
the radio up and is later given a different SSID or password, it leaves and
joins again with the new ones.

`connect()` never blocks. Starting the Wi-Fi driver takes seconds, so the
driver calls are made by a small task (`uio-radio`, on core 0) and `connect()`
returns `UIO_NOT_READY` until the network is joined, `UIO_INVALID_ARGUMENT` for
an empty SSID, and `UIO_IO_ERROR` if the task could not be created. Calling it
every cycle is fine.

`disconnect()` powers the radio down rather than merely disconnecting, which is
the saving that matters on a battery; a later `connect()` brings it back. It
only turns off a radio that `connect()` started.

`address()` gives the IPv4 address as a host-order word and `signalStrength()`
the RSSI in dBm; both read 0 while down. `radioDrops()` counts how often the
link has dropped since power-up, `radioDropReason()` gives the ESP-IDF reason
code for the last drop (255 for a lost IP address), and `radioDropText()` puts
that code into words.

### Radio duty cycling

```cpp
uio().setRadioPolicy(true, 300, 3600);   // awake 5 min on request, and once an hour

void loop() {
  uio().update();
  if (uio().takeShortPress()) uio().requestRadio();
  if (uio().radioWanted()) uio().connect(ssid, password);
  else uio().disconnect();
}
```

With a policy enabled the radio is meant to be off. `requestRadio()` wants it
for `awakeSeconds`, and `wakeEverySeconds` (0 for never) wants it on a timer.
The policy only decides whether the radio is wanted: `radioWanted()` and
`radioAwakeLeft()` report that, and the program powers the radio with
`connect()` and `disconnect()`, so one place owns it. Without a policy
`radioWanted()` is always true.

## Sleep

```cpp
const bool outputs[4] = {true, false, false, false};   // DO01 stays on
uio().sleep(UIO_DEEP_SLEEP, 60, 0x01, false, true, outputs);
```

`sleep(mode, seconds, wakeInputMask, hatInSleep, ledInSleep, outputsInSleep)`:

- `seconds` wakes on a timer; 0 for none.
- `wakeInputMask` is one bit per digital input, bit 0 being DI01. Any selected
  input going high wakes the board.
- Give a timer, a mask or both. With neither there would be no way back, and the
  call returns `UIO_INVALID_ARGUMENT`.
- `hatInSleep` keeps the HAT power enable on while asleep.
- `ledInSleep` leaves the LED a dim blue while asleep, otherwise it goes dark.
- `outputsInSleep` is the level DO01..DO04 hold while asleep, four entries, or
  `nullptr` for all off.

Light sleep keeps RAM and returns where it left off. The outputs and the HAT
enable are left at their sleep levels on return, so set them again if the
program wants something else. The LED goes back to the health colour at the
next `update()`, or, if the program has taken it with `setLed()`, stays at its
sleep colour until the program sets it again. Deep sleep does not
return: the board restarts and `wakeCause()` (`UIO_WAKE_TIMER`,
`UIO_WAKE_INPUT`, or `UIO_WAKE_RESET` for a start that was not a wake) says
why. If the chip rejects the request because a wake source is already active,
`sleep()` returns `UIO_NOT_READY` and the outputs carry on as before.

**DO05..DO08 drop through deep sleep regardless**: they are GPIO 33-36, outside
the ESP32-S3's RTC domain, and cannot be held. DO01..DO04 (GPIO 11-14) and the
HAT enable can be, and are. `begin()` releases the hold after a wake.

## Restart and diagnostics

`restart()` restarts the controller as the reset button would. Under OpenPLC it
first saves the program's retained values (see below).

`resetReason()` is the ESP-IDF `esp_reset_reason_t` of the last start: 1
power-on, 3 software restart, 4 panic, 5 to 7 watchdogs, 8 deep sleep, 9
brownout.

`systemInfo()` fills a `UioSysInfo` with free and minimum internal heap, the
largest allocatable block, PSRAM free and total, the smallest task stack margin
it can find, uptime and the reset reason.

`uioAllocLarge()` and `uioFreeLarge()` allocate from PSRAM when the board has
it and from the internal heap otherwise.

## Expansion bus

The board has two I2C buses. Its own ADS1115 (0x49) and GP8413 (0x5F) sit alone
on `Wire1` (SDA_0/SCL_0, GPIO 37/38). The Grove connectors and the HAT header
bring out the expansion bus, `Wire` (SDA_1/SCL_1, GPIO 48 SDA / 47 SCL), which
`begin()` starts.

`i2cProbe(address, present)` checks whether an address answers on the
expansion bus, and `expansionBus()` returns a `UioXBus` for driver code. Every
transaction through either takes the expansion bus lock, so two tasks never
interleave on the wire. A sketch that uses `Wire` directly for its own devices
on the Grove connectors should not do so from a different task at the same
time.

`NodeUioExpansion.h` defines a small framework for expansion modules: a table
of module kinds (`UioXKind`), a driver interface (`UioXDriver`) and an engine
(`UioXEngine`) that tracks presence, retries and channel values, doing at most
one bus transaction per `service()` call so the cycle time stays flat. **No
chip drivers are built in yet.** A module that answers at its address is
reported `UIO_XS_NO_DRIVER` and its channels are never trusted. A driver can be
supplied with `uioXRegisterDriver()`.

## With OpenPLC

The OpenPLC function blocks for this board live in
[modbee-openplc](https://github.com/mod-bee/modbee-openplc) and call this
library, so the logic has one home rather than two.

Retained variables (`RETAIN`) use the OpenPLC firmware's own store. This library
does not store them itself; `restart()` and a deep `sleep()` ask the firmware to
commit what it is holding first, because neither goes through the STOP that
would otherwise do it. On firmware without that hook, and in a plain Arduino
sketch, there is nothing to save and the call goes ahead.
