Node UIO

Node UIO

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Library API

API

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

#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

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

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.

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), 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}.

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

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

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

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

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

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 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.

Last updated 2 hours ago
Was this helpful?
Thanks!