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.