An Arduino library for ESP32-based sensor nodes: Wi-Fi provisioning, automatic reconnect
across moves, and posting readings to larsi.org's sensor
ingest API over HTTPS. Bring your own sensors - a sketch declares its channels once, reads
values in loop(), and the library handles the network. Powers the
ESP32 Sensor Node project's batcave station.
Source, docs, and license: github.com/larsi-org/sensor-node
#include <SensorNode.h>
SensorNode node;
const std::vector<SensorNodeChannel> kChannels = {{0, "BME280", "Temperature", "C"}};
// ^ decimalPlaces omitted - defaults to 1, see "Channels" below
void setup() {
Serial.begin(115200);
node.begin(); // connects, or opens the setup portal
if (node.needsProvisioning()) node.provision(kChannels); // only right after a portal save
}
void loop() {
float temperatureC = readTemperature();
node.log(kChannels, {temperatureC});
delay(node.config().logIntervalMinutes * 60UL * 1000); // set via the portal
}
Every channel a sketch reports - its id, what hardware it is, and how to describe it - lives in one table, declared once and shared by everything else in this library:
const std::vector<SensorNodeChannel> kChannels = {
{0, "BME280", "Temperature", "C", "Temp", 1},
{1, "BME280", "Relative Humidity", "%", "Humid", 0},
};
id is the channel's 0-15 offset within the device (see "Data Channel" in the
wire protocol docs for how that combines with a device id
into the server's actual addressing); sensor/property/unit
are what get registered server-side the first time a channel is seen. label and
decimalPlaces are never sent to the server at all - provision() only
reads the first four fields - they're here purely so a sketch has one place to pull a short
display label and a real rounding precision from (matching a sensor's actual accuracy, not its
raw register resolution), instead of a second list kept in sync by hand. Both default
(label to "", decimalPlaces to 1 - most
hobby-grade sensors' real accuracy backs up one decimal place, not two) so a sketch that doesn't
care about either can just leave them unset.
log() takes that same channel list plus the readings themselves, zipped
positionally against it - values[0] is channels[0]'s reading,
values[1] is channels[1]'s, and so on:
node.log(kChannels, {temperatureC, humidityPct});
Each entry picks up its id (for wire position) and decimalPlaces
(for rounding) straight from the matching channel - nothing to repeat at the call site. A
NAN value skips that channel entirely instead of logging a zero, and
values can be shorter than channels to report only the first several.
Internally, log() fills any lower, unmentioned channel ids in between with a
skipped value itself, so a device whose only far-out channel is 15 (battery, see below) never
needs eleven blank entries written out by hand.
The one real trade-off: since the match is positional, not by id, reordering
channels without updating every log() call built against it would
silently misfile a reading onto the wrong channel - worth knowing, not a reason to avoid it.
provision(channels) registers a device's name and each channel's identity with
the server, so reports show real labels instead of raw channel numbers. It's optional -
log() works fine with no provisioning at all - and, since it's a non-empty upsert
server-side (a blank field never overwrites one set by hand, but a real value does), harmless to
call more than once. But it can't run from inside the setup portal itself - the device isn't
online as a station yet at that point - and there's no need to call it every boot either. Gate
it on needsProvisioning(), which stays true only from the moment the portal saves
new settings until the next confirmed provision() response:
if (node.needsProvisioning()) node.provision(kChannels);
A node has no hardcoded Wi-Fi credentials. If begin() can't connect to any
network it already knows, it opens its own open access point
(SensorNode-Setup-XXXXXX) with a captive setup page: pick a network from a live
scan, enter the password, and set a device name (required - defaults to
"Weather <last 6 MAC hex digits>" so a fresh device never ships blank), device
id, API key, and how often to report. Up to 3 networks are remembered, most-recently-added
first, so a node that moves between a couple of locations (a workbench and its final install
spot, say) reconnects automatically on the way back instead of needing reprovisioning every
time.
The portal can also be reached on demand -
checkPortalButton(pin) reads a button at boot: a short hold opens it pre-filled (for
tweaking one field, nothing erased), a longer hold wipes saved config first so it comes up blank
instead. And it can open on its own, without touching any button:
checkFirmwareVersion(version) compares against what the device last booted with and
forces one portal revisit on a mismatch - handy for pushing a newly added config field out to an
already-deployed device without physical access.
SensorNodeBattery wraps the MAX17048 fuel gauge some boards in this family carry
onboard (e.g. the SparkFun ESP32-C6 Thing Plus). Channel 15 - the last of each device's 16 - is
reserved sitewide for battery state of charge, so every board with a fuel gauge uses the same
channel id:
{SensorNodeBattery::kSocChannel, "MAX17048", "State of Charge", "%", "Batt", 0}
readSOC() returns NAN (which log() then skips) if
begin() was never called or the chip didn't respond on the I2C bus - a board with
no fuel gauge wired up just silently omits that channel rather than needing special-case code.
Since a node connects over regular Wi-Fi rather than a proprietary radio, standard network
tools can confirm whether it's actually on the network without needing physical access to the
device itself. arp-scan lists every device on the local segment along with its MAC
vendor, which is useful because ESP32 boards' MAC addresses all resolve to "Espressif Inc." -
an easy way to spot candidate nodes among everything else on the network, though it won't tell
one node apart from another (or from a non-sensor-node ESP32 project, like a
CYD display):
sudo arp-scan --localnet
nmap -sn (a ping sweep) goes further: if the router's DNS resolves DHCP-supplied
hostnames - which most home routers do - it prints each device's actual hostname next to its IP,
which is the device name set in the setup portal:
sudo nmap -sn 192.168.1.0/24 # Nmap scan report for Weather-Basement (192.168.1.123)
If a node's hostname doesn't show up this way, check the router's DHCP client list directly - some routers only register a lease's hostname string once, at first connection, and won't update it if a device's name changes later.