From a single sensor to a fleet of 100 nodes - here's how it all works.
IOnode is a firmware. Flash it onto an ESP32, and the chip becomes a node on your NATS network. Every pin, sensor, and actuator gets a unique address. Send a message, get a response. That's it.
From your browser or PlatformIO. Takes 30 seconds. Works on any ESP32 - C6, S3, C3, or classic.
On first boot, a setup portal appears. Enter your WiFi and NATS server address. One form, done.
Every GPIO pin, ADC channel, and registered sensor gets a NATS address. Read a temperature: ionode read ionode-01 room_temp
Every IOnode has a name. Every sensor has a name. Put them together - that's the address. $ ionode read ionode-01 room_temp → 24.5 $ ionode write ionode-01 fan 1 → ok $ ionode discover → all nodes respond No SDK. No cloud. No API keys. Just NATS messages.
The CLI, web UI, and dashboard are convenience layers. Underneath, every operation is a simple NATS subject. Script IOnode in any language with a NATS client.
| GPIO & Peripherals | |
|---|---|
| {name}.hal.gpio.{pin}.get | Read pin → 0 or 1 |
| {name}.hal.gpio.{pin}.set | Write pin → ok |
| {name}.hal.adc.{pin}.read | 12-bit ADC → 0–4095 |
| {name}.hal.pwm.{pin}.set | 8-bit PWM → ok |
| {name}.hal.uart.read / write | Serial bridge (requires serial_text device) |
| I2C | |
| {name}.hal.i2c.scan | Scan bus → array of addresses |
| {name}.hal.i2c.{addr}.read | Read register bytes |
| {name}.hal.i2c.{addr}.write | Write register bytes |
| {name}.hal.i2c.recover | Bus recovery (9× SCL toggle) |
| Registered Devices | |
| {name}.hal.{dev} | Read sensor or actuator state |
| {name}.hal.{dev}.set | Set actuator value → ok |
| {name}.hal.{dev}.info | JSON: name, kind, value, pin, unit |
| Discovery & System | |
| _ion.discover | All nodes respond with capabilities |
| {name}.capabilities | Single node capabilities |
| {name}.hal.system.* | Chip temp, heap, uptime, RSSI, reset reason |
| {name}.hal.device.list | JSON array of all registered devices |
Full protocol reference on GitHub.
21 device types across 6 categories. Register via CLI, NATS, web UI, or devices.json.
digital_in · digital_out · analog_in · pwm · relay (with inversion)
Direct pin access. Read switches, drive motors, toggle relays.
ntc_10k · ldr
Thermistor (Steinhart-Hart, EMA-smoothed) and light level (0–100%). One resistor, done.
i2c_bme280 · i2c_bh1750 · i2c_sht31 · i2c_ads1115 · i2c_generic
Raw Wire.h drivers. Multi-channel, per-address caching. Any I2C sensor via i2c_generic.
ssd1306 · sh1106
128×64 or 128×32 text display. Template engine with live sensor tokens, auto-refreshes every 5s.
internal_temp · rgb_led · clock_hour · clock_minute · clock_hhmm
No wiring needed. Chip temperature, RGB LED, and NTP clock - ready at boot.
nats_value · serial_text
Subscribe to NATS subjects or read UART lines. Bridge external data into the device registry.
[
{"n":"bme_temp", "k":"i2c_bme280", "p":0, "u":"C", "i":false, "ia":118},
{"n":"bme_humi", "k":"i2c_bme280", "p":1, "u":"%", "i":false, "ia":118},
{"n":"light", "k":"i2c_bh1750", "p":0, "u":"lux","i":false, "ia":35},
{"n":"display", "k":"ssd1306", "p":0, "u":"", "i":false, "ia":60,
"dt":"T:{bme_temp}C H:{bme_humi}%"},
{"n":"heater", "k":"relay", "p":8, "u":"", "i":true},
{"n":"fan", "k":"pwm", "p":9, "u":"", "i":false}
]
Each IOnode serves a control interface on port 80. Configure, monitor, and control from any browser.
WiFi, NATS, device name, timezone. Live devices.json editor.
All registered devices with kind-appropriate widgets. Add/remove devices.
Direct GPIO/ADC/PWM access. No registration needed. Quick wiring checks.
Version, uptime, heap, WiFi signal, NATS connection state.
Everything you need to manage nodes at scale - from one to hundreds.
28 commands with true color output. Discover, read, write, tag, configure, monitor - all from the terminal.
Single HTML file. Connects via NATS WebSocket. Live node cards, actuator controls, event log, tag filtering.
Threshold events with edge detection and cooldown. Periodic heartbeats with heap, RSSI, uptime. Dead node detection.
Adding a sensor type is a two-step process. No NATS code. No registration boilerplate.
enum DeviceKind { // ... existing kinds ... DEV_SENSOR_MY_SENSOR, // ← add here };
case DEV_SENSOR_MY_SENSOR: { result = readMyHardware(dev->pin); record_history = true; break; } // deviceKindName: case DEV_SENSOR_MY_SENSOR: return "my_sensor"; // kindFromString: if (strcmp(s, "my_sensor") == 0) return DEV_SENSOR_MY_SENSOR;
Your sensor is now: ✓ Persisted in devices.json as {"k":"my_sensor"} ✓ Readable via nats req ionode-01.hal.my_sensor "" ✓ Listed in device.list and discovery responses ✓ Background-polled for history every 5 minutes ✓ Controllable via the web UI No NATS code. The HAL router handles it.
All targets use a 2MB partition layout. IOnode is lean, ~202KB free heap.
| ESP32-C6 | Default target · USB-CDC serial · RGB LED · RISC-V |
|---|---|
| ESP32-S3 | More RAM · RGB LED · Dual-core Xtensa |
| ESP32-C3 | Smallest, cheapest · RISC-V · No on-die temp on some variants |
| ESP32 | Classic · Dual-core Xtensa · No on-die temp sensor |
| Partition | app0 (1.69 MB) + spiffs (256 KB) + nvs (20 KB) |
|---|---|
| Free Heap | ~202 KB |
| Framework | Arduino + PlatformIO (pioarduino) |