Fluxgrid Arduino Library
The simple ESP32 client for the Fluxgrid IoT platform.
Complex inside (WiFi, MQTT, auto-reconnect, Last-Will online/offline, topic plumbing), simple outside. A complete device is about ten lines:
#define WIFI_SSID "wifi"
#define WIFI_PASS "pass"
#define FG_TOKEN "DEVICE_TOKEN" // one string per device, from the dashboard
#include <Fluxgrid.h> // ← credentials must be #defined ABOVE this line
void setup() {
Fluxgrid.begin(); // server, port and TLS are built in
}
void loop() {
Fluxgrid.run();
Fluxgrid.write("temp", analogRead(4) * (50.0 / 4095.0)); // push a reading
digitalWrite(2, Fluxgrid.read("relay").asBool()); // read a Switch back
}
"temp" and "relay" are datastream handles — the short name each widget
shows on the dashboard. Drop a widget on the canvas and it creates a datastream
for you; use that handle here. Pick anything readable (temp, relay,
pump, red); it's what travels in the MQTT topic.
The bundled examples keep each handle in one place as a #define ending in
_HANDLE, then use that macro everywhere instead of repeating the string — so
matching the code to your widget is a one-line edit:
#define RELAY_HANDLE "relay" // must match your Switch widget's datastream handle
// ...
Fluxgrid.onReceive(RELAY_HANDLE, [](FluxValue v){ digitalWrite(2, v.asBool()); });
Rename the widget's handle on the dashboard (or change the #define) so the two
agree — the Sample Code window in the editor does this for you when you pick a
Datastream from its toolbar.
Install
Requirements: ESP32 Arduino core + PubSubClient (install from Library Manager).
Then install Fluxgrid one of three ways:
- Arduino IDE → Library Manager — search "Fluxgrid" and install, or
- Sketch → Include Library → Add .ZIP Library… and pick the downloaded ZIP, or
- copy the
Fluxgrid/folder into yourArduino/libraries/directory.
Restart the IDE; you'll find examples under File → Examples → Fluxgrid.
Every dashboard widget has a folder there, and the first sketch in each is named
…Basic: the smallest thing that makes that one widget work, with no sensor
and nothing wired to the board. A control widget's Basic sketch prints what it
receives to the Serial Monitor; a display widget's makes its own number up. Start
there, confirm the tile moves, then swap in your real hardware — the other
sketches in the same folder show how.
Get your credentials
In your dashboard: add a device → "Pair device". Each device has one
token — copy it into FG_TOKEN. That single string bundles the device's
routing token + its own MQTT user/pass (joined as <token>.<user>.<pass>); the
library splits it for you, so you only ever paste one value per device. The
pairing screen shows a ready-to-flash sketch with it filled in.
The three
#defines must come before#include <Fluxgrid.h>— the library reads them to implement the zero-argumentFluxgrid.begin(). (Arduino compiles your sketch and the library separately, so a value defined after the include is invisible to it.)
API
| Call | What it does |
|---|---|
★ Fluxgrid.begin() | Connect WiFi + cloud using WIFI_SSID / WIFI_PASS / FG_TOKEN. Call once in setup(). |
Fluxgrid.begin(ssid, pass, token, user, pass) | Explicit form, if you'd rather pass the split values than #define them. |
★ Fluxgrid.run() | Keep the link alive + deliver writes. Call every loop(). |
★ Fluxgrid.write(handle, value) | Push telemetry to a datastream ("temp", …). Overloaded: int / long / unsigned int / unsigned long / float / double / bool / String. The unsigned overloads let you pass ESP.getFreeHeap() and other uint32_t values directly. Safe to call every loop() — see How write() paces itself. <!-- cheat: Push telemetry to a datastream. Safe to call every `loop()` — the library paces it for you. --> |
★ Fluxgrid.read(handle) | Latest value for a datastream, as a FluxValue — convert with .asInt() / .asFloat() / .asBool() / .asString(). .isEmpty() is true until the first value arrives. |
★ Fluxgrid.has(handle) | true once any value has been received for the handle. |
★ Fluxgrid.onReceive(handle, fn) | Fire a callback once per incoming write. The lambda gets a FluxValue: onReceive("relay", [](FluxValue v){ … v.asBool() … }). Register it before begin(); a named void fn(FluxValue) works too — see Three ways to handle an incoming value. <!-- cheat: Fire a callback once per incoming write. The lambda gets a `FluxValue`: `onReceive("relay", [](FluxValue v){ … v.asBool() … })`. --> |
★ Fluxgrid.onConnected(fn) | Runs each time the cloud connection is (re)established. |
Fluxgrid.enableOTA(password[, hostname]) | Arm LAN firmware updates over WiFi. Call before begin() — see OTA updates. |
Fluxgrid.reportMemory(intervalMs = 5000) | Auto-publish heap (+ PSRAM) every intervalMs in one message for the Memory Chart widget — call once in setup(). 0 turns it off. |
Fluxgrid.led(on) / Fluxgrid.led(r, g, b) | Drive the board's own LED — no GPIO, no addLed(), no wiring. Uses whatever the board declares (RGB_BUILTIN → the onboard WS2812, else LED_BUILTIN). Returns false if the board has neither. Safe to call every loop(). |
★ Fluxgrid.connected() | true when linked to the cloud. |
Fluxgrid.writeWiFiScan(handle = "wifi_scan", maxNets = 16) | Scan nearby WiFi and publish it (sorted, JSON) to a WiFi Scan widget. Blocks 1–4 s; call on a timer. Returns the count. |
Fluxgrid.startWiFiScan() / Fluxgrid.pollWiFiScan(handle, maxNets) | Non-blocking WiFi scan — start once, poll every loop() (-1 running, -2 none, ≥0 done & published). See WiFi Scan widget. |
★ marks the calls that also appear on the one-page Cheat sheet — it is built from these rows, so this table stays the only place they are written down.
Options (call before begin): only needed for a self-hosted broker or to
pin the broker's certificate — the official cloud works with no options.
How write() paces itself
The obvious sketch calls write() straight from loop():
void loop() {
Fluxgrid.run();
Fluxgrid.write("button", digitalRead(0) == LOW);
}
loop() runs thousands of times a second, so written literally that is
thousands of identical messages a second on the broker and in your serial
monitor. Since 0.19.0 the library shapes that for you, and the code above is
simply correct:
| Unchanged value | Not sent. Re-sent every 10 s, so a dashboard is never left showing a value the device has stopped confirming. |
| Changed value | Sent immediately — unless something went out for that handle less than 100 ms ago, in which case it waits out the gap. It is held, not dropped: the newest value wins and goes out when the gap ends. |
| Bouncing contact | Several transitions inside one 100 ms gap collapse to the settled value. You get debouncing without writing one. |
| Dropped packet | Telemetry is QoS 0, which the broker may discard without an error. A changed value is therefore sent once more ~300 ms later, so a lost "button released" can't leave a dashboard lamp stuck on. |
Tune with -D FLUXGRID_WRITE_MIN_GAP_MS=…, …_RESEND_MS=…,
…_CONFIRM_MS=… if you need a faster stream or a quieter one.
Choosing a region
Fluxgrid runs one cloud per region, each with its own broker, accounts and devices. A device belongs to exactly one of them — the region you signed up in. Its credentials do not exist on the other, so a device pointed at the wrong region will fail to authenticate.
| Region | Broker | Dashboard |
|---|---|---|
| Global (default) | mqtt.lonelybinary.com | https://fluxgrid.lonelybinary.com |
| China | mqtt.lonelybinary.cn | https://fluxgrid.lonelybinary.cn |
The global cloud is the default, so most sketches need no region line at all.
On the China cloud, add one call before begin():
void setup() {
Fluxgrid.region("cn");
Fluxgrid.begin();
}
The sketch the dashboard generates already contains the right line for the region you are signed in to, so if you copy from Pair device you can ignore this entirely.
| Call | Default |
|---|---|
★ Fluxgrid.region(code) | "com" — the global cloud. Use "cn" for the China cloud. <!-- cheat: Point the device at a region's cloud — `"com"` (default) or `"cn"`. Call before `begin()`. --> |
Fluxgrid.setServer(host, port) | mqtt.lonelybinary.com : 8883 |
Fluxgrid.secure(on) | true (MQTTS/TLS) — call secure(false) for a plaintext broker |
Fluxgrid.setCACert(pem) | not set — TLS encrypts but accepts any server cert. Pass your broker's root CA (PEM) to pin it; see Security note. |
Managing WiFi yourself
By default Fluxgrid owns WiFi: begin() joins WIFI_SSID / WIFI_PASS in STA
mode and reconnects for you — the ten-line quickstart. If you'd rather control
the radio (to provision with WiFiManager / Improv, run an access
point, or share the connection with your own code), take it over:
| Call | What it does |
|---|---|
Fluxgrid.manageWiFi(false) | Call before begin(). The library never touches the radio — you bring up STA / AP / AP+STA yourself and it rides on top. run() only observes the link (no mode changes, no reconnect kicks, never blocks). With this off, begin() needs only FG_TOKEN — no WiFi credentials. |
Fluxgrid.cloud(false) | Call before begin(). Skip the MQTT broker entirely — a pure local device (e.g. an offline AP). Default on. |
Fluxgrid.startAP(ssid, pass = nullptr) | Bring up the device's own access point (AP+STA, so an existing STA link and the cloud keep working). Pass no password for an open AP. Safe to call after begin(). |
Fluxgrid.stopAP() | Tear the access point back down. |
#define FG_TOKEN "DEVICE_TOKEN" // no WIFI_SSID / WIFI_PASS needed
#include <Fluxgrid.h>
#include <WiFi.h>
void setup() {
WiFi.mode(WIFI_STA);
WiFi.begin("ssid", "pass"); // or WiFiManager / Improv here
while (WiFi.status() != WL_CONNECTED) delay(250);
Fluxgrid.manageWiFi(false); // hands off the radio
Fluxgrid.begin(); // cloud rides on your connection
}
void loop() { Fluxgrid.run(); }
See the ByoWiFi example (File ▸ Examples ▸ Fluxgrid). On the ESP32 the AP and STA share one radio, so both end up on the same channel — a STA reconnect can briefly bump connected AP clients.
Default behaviour is unchanged: existing sketches that
#define WIFI_*and callbegin()keep working exactly as before — these calls are all opt-in.
Local dashboard (offline / AP) — FluxgridLocal
Serve a dashboard from the ESP32 itself — its own access point or your LAN —
with no cloud. This lives in a separate header you opt into with an
#include, so cloud-only sketches never pull in the web-server dependencies:
#include <Fluxgrid.h>
#include <FluxgridLocal.h> // adds ESPAsyncWebServer + LittleFS — only here
#include <WiFi.h>
void setup() {
Fluxgrid.manageWiFi(false);
WiFi.mode(WIFI_AP);
WiFi.softAP("Fluxgrid-Device", "fluxgrid123");
Fluxgrid.cloud(false); // optional: pure local, no broker
Fluxgrid.begin();
FluxgridLocal.startWeb(); // → http://192.168.4.1/
}
void loop() {
Fluxgrid.run();
FluxgridLocal.loop(); // pumps the captive-portal DNS
Fluxgrid.write("temp", readTemp()); // shows live on the local dashboard
}
The data bridge is automatic: Fluxgrid.write() is pushed to browsers over SSE,
and a control widget tapped in the browser calls your Fluxgrid.onReceive() —
the same callback cloud control writes use. Until a dashboard is pushed
(from the Fluxgrid app's Push to device — see below), the device serves a
small onboarding page instead of a 404.
| Call | What it does |
|---|---|
FluxgridLocal.startWeb(captivePortal = true) | Start the local web server (port 80). In AP mode also runs a captive portal. Call after Fluxgrid.begin() and after WiFi is up. |
FluxgridLocal.stopWeb() | Stop the web server. |
FluxgridLocal.loop() | Call every loop() — pumps the captive-portal DNS. |
FluxgridLocal.url() | The best URL to reach it (AP IP, else LAN IP). |
Requires (install via Library Manager): ESPAsyncWebServer, AsyncTCP,
ArduinoJson, and a Partition Scheme with a LittleFS partition. See the
LocalDashboard example. Note the local AP has no TLS — gate the device
before exposing it on an untrusted network.
Push a dashboard from the cloud editor
The editor's Manage ▸ Push to device… sends the current page's layout to the
device over the cloud: the app stages the (filtered) dashboard.json and the
device pulls it onto LittleFS /dashboard.json, which FluxgridLocal then
serves offline. Online-only widgets (maps, fleet, camera, file explorer…) are
dropped automatically; the modal previews exactly what ships.
The player bundle travels with the push. FluxgridLocal serves its
dashboard only when BOTH the layout and the player are on flash, so the editor
sends the player (~400 KB) the first time, stamps /version.json with its build
id, and afterwards ships only the few-KB layout — until a new player build makes
the ids differ. The device reports the id it carries as local.playerVer, so
nothing has to be re-sent to find out.
Since 0.21 you write no code for this. begin() mounts the board's LittleFS
partition and exposes it to the cloud in a limited mode, so a device is ready
to receive a push out of the box. Limited means exactly this:
| The cloud may | The cloud may not |
|---|---|
write /dashboard.json, /player.html[.gz] and /version.json | write anything else |
list / stat (so the editor can see what's there) | read a file back (pull) |
mkdir / delete / rename / format |
pull is refused on purpose: it is the one command that copies a file's bytes
off the board, and this channel is now open on devices whose author never asked
to expose their flash. Everything else about your sketch is unchanged.
The partition is mounted, never formatted. A board whose flash already holds your data is left alone; one with no LittleFS partition (or an unformatted one) simply keeps the file channel off and logs why.
So for the device to receive a push it must, at push time:
- be online and cloud-connected —
Fluxgrid.cloud(true)(the default) over real WiFi; an AP+STA setup receives the push and keeps serving offline afterwards, and - be running 0.21 or newer with a Partition Scheme that has a SPIFFS/LittleFS
region (Tools ▸ Partition Scheme). On an older library, add
Fluxgrid.addVolume("flash", LittleFS, FG_FLASH)+Fluxgrid.enableFiles()yourself.
The device reports whether a push can work (0.22+). On connect — and again
whenever the answer changes — the library publishes two blocks on the retained
meta topic, which is what the editor's readiness panel reads:
| Reported | Means |
|---|---|
fs.mode | off (nothing writable — a push cannot land), limited (the automatic volume), full (you called enableFiles()) |
fs.why | why it is off: no_partition, or sketch (you registered volumes but never called enableFiles()) |
fs.total / fs.used | space on the flash volume |
local.server / local.url / local.ap | whether an on-device web server is serving the dashboard, and where |
local.player / local.dashboard | whether the two files the offline dashboard needs are actually on flash |
local.playerVer | which player build is on flash, read from the /version.json the cloud stamps beside it |
FluxgridLocal.startWeb() fills the local block in for you. Serving the
dashboard from a web server of your own instead? Say so with
Fluxgrid.reportLocalServer(true, "http://192.168.4.1") (and false when it
stops), or the editor will keep telling you no server is running.
Only the target device's widgets are pushed. A page can mix widgets from several boards, but offline a board serves only the values it writes — a widget bound to another board's datastream could never fill in. Those are listed as skipped in the push dialog rather than shipped as permanently empty tiles.
When a push times out. The editor's first step is a volumes probe, so a
device that never opened the file channel simply doesn't answer and the push
reports a timeout. The reason is in the serial log, on the line right after
starting (...):
| Log line | What it means |
|---|---|
files: flash mounted, limited mode ... | ready to receive a push |
files: no mountable LittleFS partition ... | no SPIFFS/LittleFS region in the Partition Scheme, or the region has never been formatted (begin() mounts, it never formats). Format it once with LittleFS.begin(true) in a throwaway sketch. |
files: volumes registered but the file channel is off ... | your sketch called addVolume(), which stands the automatic volume down — add Fluxgrid.enableFiles() |
(no files: line at all) | a FLUXGRID_NO_FILES build |
On the connect line you can confirm it the other way round: a device with the
channel open also logs cloud: file explorer on, subscribed .../fs/req.
Full file access is still opt-in. Call addVolume() + enableFiles() and
you get the whole File Explorer (browse, download, upload, rename, delete,
format) on every volume you register — registering anything of your own also
turns the automatic volume off, so a sketch written before 0.21 behaves exactly
as it did.
Opting out. The automatic volume costs ~38 KB of flash (the LittleFS driver; measured on an ESP32-S3 with core 3.3.10) and ~2 KB of heap while mounted. To get all of it back:
#define FLUXGRID_NO_FILES 1
#include <Fluxgrid.h>
It has to be a macro rather than a runtime call — Arduino compiles this library separately from your sketch, so nothing else can keep the driver out of the binary.
Receiving is only half of an offline dashboard: to serve it, the device also
needs FluxgridLocal and the player bundle. The LocalDashboard example is
pure-offline (cloud(false), AP only) and so cannot receive a cloud push; use
LocalDashboardCloud (AP+STA) for both halves. The player bundle
(player.html.gz) is flashed/uploaded separately; a routine push only re-sends
the small layout JSON.
Serial monitor on the dashboard
Want your device's serial output in the browser, not just over USB? Drop a
Terminal widget on the canvas (Display group) and bind it to a log
datastream — then send lines to it one of two ways. Both tee to both the USB
Serial Monitor and the cloud, share one line buffer, and publish each completed
line (on \n) to the log handle (default "log"; change with
Fluxgrid.setLogHandle("dbg")). See the SerialMonitor/CaptureSerial
(Method A) and SerialMonitor/ExplicitPrint (Method B) examples.
A — zero code change. #define FLUXGRID_CAPTURE_SERIAL 1 before the
include and keep writing Serial.print / println / printf exactly as you do
now:
#define FLUXGRID_CAPTURE_SERIAL 1
#include <Fluxgrid.h>
...
Serial.println("hello"); // → USB Serial Monitor *and* the dashboard
B — explicit. Leave the macro off and call Fluxgrid.println(...) (a full
Print: print / println / printf) for just the lines you want online; the
real Serial is left completely untouched:
Fluxgrid.println("hello"); // → USB + dashboard, Serial unchanged
| Call | What it does |
|---|---|
FLUXGRID_CAPTURE_SERIAL (define 1 before include) | Alias Serial to the tee so existing Serial.* calls also reach the dashboard. Default 0 (off). |
Fluxgrid.println(...) / .print(...) / .printf(...) | Explicitly tee a line to USB + the log datastream, without redefining Serial. |
Fluxgrid.setLogHandle("dbg") | Publish captured lines to a different datastream handle (default "log"). |
Things to know:
FLUXGRID_CAPTURE_SERIALreplaces theSerialtoken. It only affectsSerialafter the include in that.ino(Serial1/Serial2are untouched; the library's own internals stay on the real UART). Because it's a blunt text substitution, takingSerial's address or binding it to aHardwareSerial&won't compile, and other libraries#included after Fluxgrid that useSerialin inline/header code get captured too — so put#include <Fluxgrid.h>last, or use route B instead.- Lines reach the cloud only once connected. Early boot output is USB-only (it still prints there). Nothing is back-filled on connect.
- The cloud rate-limits one stream to a few lines/sec. Very chatty logging is throttled on the server (the USB monitor always shows everything).
- A line that's purely a number (e.g.
42) is stored as a numeric value, not a log line. Mixed text is fine.
LED control
The board's own LED
Most dev boards have an LED on them already. You do not need to know which pin, or whether it is a plain LED or an addressable RGB:
Fluxgrid.led(true); // on
Fluxgrid.led(false); // off
Fluxgrid.led(0, 120, 90); // a colour, if the board's LED is RGB
Fluxgrid.led(Fluxgrid.read("led").asBool()); // ...driven by a Switch widget
This follows RGB_BUILTIN / LED_BUILTIN as published by the ESP32 core, so
the same sketch works on a board that wires its RGB to 48, 38 or 21 — a real
difference between vendors selling the "same" ESP32-S3 DevKit. It returns
false if the board declares no built-in LED; use addLed() below with a pin
of your own in that case. Repeated calls with the state it is already in do
nothing, so it is safe in loop().
LEDs you wire yourself
The library has built-in support for two kinds of LEDs with a unified API — no external LED library needed.
| Constant | Meaning |
|---|---|
LED_NORMAL | Regular LED wired to a GPIO (default) |
LED_WS2812 | Single WS2812 / WS2812B RGB LED, driven via the ESP32 RMT peripheral |
Register your LEDs once in setup(), before Fluxgrid.begin():
Fluxgrid.addLed(26); // regular LED on GPIO 26
Fluxgrid.addLed(48, LED_WS2812); // WS2812 on GPIO 48 (default: white)
Fluxgrid.addLed(48, LED_WS2812, 255, 80, 0); // WS2812 with a custom default colour (R, G, B)
Control:
Fluxgrid.ledOn(26); // regular LED → HIGH
Fluxgrid.ledOff(26); // regular LED → LOW
Fluxgrid.ledOn(48); // WS2812 → last stored colour
Fluxgrid.ledOn(48, 0, 200, 255); // WS2812 → new colour (also stored as default)
Fluxgrid.ledOff(48); // WS2812 → off (0, 0, 0)
The WS2812 driver uses the ESP32's RMT hardware peripheral directly, so the signal timing is handled in hardware — it stays accurate even when WiFi and MQTT are running. No noInterrupts() hacks needed.
See examples/LedControl/LedControl.ino for a complete sketch that drives both LED types from dashboard widgets.
File explorer
Expose the device's mounted filesystems to the dashboard's File Explorer widget — browse folders, download and upload files, rename, delete, make directories and format — straight from the browser.
The split: small metadata commands (browse, rename, …) ride MQTT, while file
bytes never touch MQTT — they stream over HTTPS to/from short-lived
presigned storage URLs, on a background task, so a multi-megabyte transfer never
stalls loop().
You mount the hardware; the library never touches the SD bus or pins. Bring the filesystem up yourself —
LittleFS.begin(),SD_MMC.begin(),SD.begin(cs)— so you keep full control of partitions, the SD mode (SDMMC vs SPI) and which pins it uses. Then register each mounted filesystem and switch the feature on.
#include <Fluxgrid.h>
#include <LittleFS.h>
#include <SD_MMC.h> // or <SD.h> for an SPI card
void setup() {
LittleFS.begin(true); // you mount it
Fluxgrid.addVolume("flash", LittleFS, FG_FLASH); // capacity auto-detected
SD_MMC.begin(); // you mount it
Fluxgrid.addVolume("sd", SD_MMC, FG_SD);
Fluxgrid.enableFiles(); // turn the explorer on
Fluxgrid.begin();
}
void loop() { Fluxgrid.run(); }
On the dashboard, drop a File Explorer widget and point it at this device. Browsing, rename, delete, mkdir and format work out of the box; download and upload additionally need object storage (MinIO) configured for your workspace.
addVolume() — two styles
Known types (zero extra args). Pass the global filesystem object and its type
constant; the library reads capacity for you (the fs::FS base class can't report
it, so this resolves against the concrete class in your sketch):
Fluxgrid.addVolume("flash", LittleFS, FG_FLASH); // on-chip flash
Fluxgrid.addVolume("sd", SD_MMC, FG_SD); // SD over SDMMC
Fluxgrid.addVolume("sd", SD, FG_SD); // …or SD over SPI
Fluxgrid.addVolume("fat", FFat, FG_FLASH); // FATFS on flash
// optional 4th/5th args: a human label and a read-only flag
Fluxgrid.addVolume("flash", LittleFS, FG_FLASH, "Internal Flash", /*readonly=*/true);
Any filesystem (escape hatch). For a filesystem the known-type path doesn't
recognise, register it as FG_FS_OTHER and supply the capacity getters yourself:
Fluxgrid.addVolume("data", myFs, FG_FS_OTHER,
[]() -> uint64_t { return myFs.totalBytes(); },
[]() -> uint64_t { return myFs.usedBytes(); },
"Data", /*readonly=*/false); // label + readonly optional
| Call | What it does |
|---|---|
Fluxgrid.addVolume(id, fs, type) | Register a known filesystem (LittleFS / SD / SD_MMC / FFat); capacity auto-detected. type is FG_FLASH, FG_SD or FG_FS_OTHER. Optional label, readonly. |
Fluxgrid.addVolume(id, fs, type, totalFn, usedFn) | Register any fs::FS, supplying capacity getters. Optional label, readonly. |
Fluxgrid.enableFiles() | Turn the file-explorer handler on. Call once in setup(). Until you call it the device never subscribes to the command channel, so sketches that don't use files pay nothing. |
Fluxgrid.reportLocalServer(up, url, ap) | Tell the cloud an on-device web server is (or is no longer) serving the offline dashboard. FluxgridLocal calls it for you; needed only for a server of your own. |
idis a short slug you choose ([A-Za-z0-9_-], ≤ 32 chars) — how the widget addresses the volume.labelis the human name on the card (defaults toid). Areadonlyvolume refuses every write (upload, rename, delete, mkdir, format) withEACCES.- Up to 4 volumes by default — raise
FLUXGRID_MAX_VOLUMES(define it before#include <Fluxgrid.h>) if a board mounts more.
LittleFS vs FATFS (flash), and the SD card
- On-chip flash — use LittleFS for most projects: it's power-loss
resilient and the default for the ESP32. FATFS (FFat) is the alternative
when you need a FAT layout (e.g. files a PC must read over USB MSC). Either
way, pick a Partition Scheme with a filesystem partition (Tools ▸ Partition
Scheme) and register it with
FG_FLASH. The dashboard's Format button calls the real.format(). - SD card — register it with
FG_SD. There is no portableformat()for SD, so on an SD volume the Format button wipes everything at the root (a full delete) rather than re-creating the filesystem. You initialise the card (SDMMC or SPI, with your pins) beforeaddVolume().
See examples/FileExplorer/FileExplorer.ino for a complete LittleFS (on-chip
flash) sketch, and examples/FileExplorerSD/FileExplorerSD.ino for an SD card
(SDMMC or SPI).
Debug logging
Debug logging is on by default. The library narrates what it's doing —
WiFi join, cloud connect, every value sent and received, config applied — to the
serial monitor, so you can see exactly what's happening while you build. Open
Tools → Serial Monitor at 115200 baud; lines are prefixed [Fluxgrid]:
[Fluxgrid] Fluxgrid library v0.20.0
[Fluxgrid] debug: events only — #define FLUXGRID_DEBUG 2 to trace every value
[Fluxgrid] starting (token=abc123, host=mqtt.lonelybinary.com:8883, tls=yes)
[Fluxgrid] WiFi: connecting to "my-wifi" ...
[Fluxgrid] WiFi: connected, IP 192.168.1.42
[Fluxgrid] cloud: connecting to mqtt.lonelybinary.com:8883 as dev_abc ...
[Fluxgrid] cloud: connected — online, subscribed fluxgrid/abc123/w/+
You don't need to call Serial.begin() yourself — above level 0, begin()
starts it for you.
The three levels
That is the whole of a normal boot: about seven lines, and then the monitor is
yours. Serial.println(myValue) in loop() stays readable because the library
has stopped talking.
When a widget isn't updating, turn the traffic on:
#define FLUXGRID_DEBUG 2
#include <Fluxgrid.h>
[Fluxgrid] write: temp = 24.50
[Fluxgrid] write: temp = 24.75 (x48 in 5.0s)
[Fluxgrid] recv: relay = 1
A datastream you write on every pass of loop() publishes about ten times a
second. Printing each one buries your own output, so level 2 prints the first
write of a datastream immediately and then folds the rest: one line per
datastream per five seconds, carrying the newest value and how many writes it
stands for. Values arriving from the dashboard (recv:, inject:) are rare
and someone-clicked-something, so those print one for one.
| Level | What you see |
|---|---|
0 | Nothing. begin() also skips the auto Serial.begin(). |
1 | Default. Version, WiFi, cloud, config, and every error. |
2 | The above, plus every value in and out (writes folded). |
You can also set it from code with Fluxgrid.debug(level) — before begin()
to replace the #define, or around one part of loop() to trace just that:
Fluxgrid.debug(2);
Fluxgrid.write("temp", t);
Fluxgrid.debug(1);
Define (before #include) | Default | What it does |
|---|---|---|
FLUXGRID_DEBUG | 1 | Log level — 0 silent, 1 events, 2 events + values. |
FLUXGRID_DEBUG_BAUD | 115200 | Baud rate begin() uses when it auto-starts Serial. |
FLUXGRID_TRACE_MS | 5000 | How long level 2 folds repeated writes to one datastream. |
OTA updates (flash over WiFi)
Once a board is running, you can send it a new sketch over WiFi instead of plugging it back in. One call arms it:
void setup() {
Fluxgrid.enableOTA("choose-a-password"); // BEFORE begin()
Fluxgrid.begin();
}
void loop() {
Fluxgrid.run(); // run() services the OTA listener for you
}
Read this before you try it. Three facts catch everybody out:
- LAN only. Your computer and the board must be on the same network. The protocol is mDNS plus UDP, and neither routes. There is no cloud OTA — the dashboard cannot push firmware to a board, and there is no firmware store.
- The first flash is always over USB. A board can only receive an OTA build
once it is already running a sketch that called
enableOTA(). - The partition scheme needs two app slots. An update is written to the slot
the board is not running from, then the bootloader switches over. With a
single-app scheme there is nowhere for it to land. Drop an ESP Partition
widget on your dashboard, pick Balanced (OTA), and use the
partitions.csvit generates (Arduino IDE: Tools ▸ Partition Scheme).
Then, from the IDE:
- Flash this sketch once over USB.
- Wait ~30 s for the board to join WiFi.
- Tools ▸ Port ▸ Network ports — the board appears as
fluxgrid-a1b2c3 at 192.168.1.42 (ESP32). - Select it and Upload. The IDE asks for the password every time.
Fluxgrid.enableOTA(password) | Arm OTA. Hostname defaults to fluxgrid- + the first six characters of your device token. |
Fluxgrid.enableOTA(password, hostname) | Same, with a hostname you choose — that is the name Tools ▸ Port shows. |
The password is required, and it is a password, not a formality: anyone on your network who knows it can replace the firmware. espota authenticates with an MD5 challenge over an unencrypted link, so treat it as LAN-trust, not as internet-grade security. Never expose port 3232 to the internet.
OTA starts as soon as WiFi is up, so it works on a Fluxgrid.cloud(false)
board and on one whose broker is unreachable — a board you can no longer talk
to over MQTT is exactly the board you most want to be able to reflash.
Everything above is reported to the dashboard in the retained meta payload
(the ota block, see Device Info widget), so the OTA
Update widget can check a real board rather than describe a hypothetical one:
armed or not, one app slot or two, whether the build still fits the target slot,
and which port name to look for. See File ▸ Examples ▸ Fluxgrid ▸ OTA ▸
LanOTA.
WiFi Scan widget
The WiFi Scan widget shows the ESP32's WiFi.scanNetworks() results as a
live Radar, Channel Spectrum, or List view with three selectable color themes.
Dashboard setup: create a datastream named "wifi_scan" (kind: json),
drop a WiFi Scan widget on the canvas, and bind it to "wifi_scan".
Sketch: the library builds and publishes the payload for you — one call:
Fluxgrid.writeWiFiScan(); // scan + sort + publish to "wifi_scan"
writeWiFiScan(handle = "wifi_scan", maxNets = 16) scans, sorts the networks
strongest-first, serializes them, and publishes — returning the count. It
blocks for 1–4 s while the radio scans, so call it on a timer (every ~10 s),
not every loop(). See File → Examples → Fluxgrid → WiFiScan → WiFiScanLoop.
Need the connection to stay responsive during the scan? Use the non-blocking pair (WiFiScanAsync example):
Fluxgrid.startWiFiScan(); // kick the radio, returns immediately
int c = Fluxgrid.pollWiFiScan(); // every loop(): -1 running, -2 none, ≥0 published
The published payload is a JSON array, strongest first:
[{"ssid":"NET","rssi":-62,"channel":4,"enc":"WPA2","bssid":"AA:BB:CC:DD:EE:FF"},…]
bssid determines where each network sits on the radar — networks with a real
BSSID always appear in the same position across scans. The payload is trimmed to
maxNets and to the MQTT buffer so it always fits.
Things to know:
- The scan takes 1–4 s; use a scan interval of ~10 s (the examples do).
- The widget shows a built-in placeholder scan while waiting for the first payload so the tile always looks alive.
- The Channel Spectrum shows 2.4 GHz channels 1–13. 5 GHz networks that happen to have the same channel number will still render (cosmetically only).
WiFi RSSI widget
The WiFi RSSI widget shows the device's own link strength — how good its connection to the router is — as a live signal monitor (arc fan, signal bars, or serial console view, with a rolling history graph and quality %).
Unlike the WiFi Scan widget (which lists every nearby network), this reports a
single number: WiFi.RSSI() in dBm, for the access point the ESP32 is joined
to. The widget derives the quality %, the strong/weak tier and the history graph
from that one value.
Dashboard setup: create a datastream named "rssi" (kind: number), drop a
WiFi RSSI widget on the canvas, and bind it to "rssi".
Sketch: see File → Examples → Fluxgrid → RssiMonitor. Publish the link strength every few seconds:
if (WiFi.status() == WL_CONNECTED) // RSSI is only valid while associated
Fluxgrid.write("rssi", WiFi.RSSI()); // dBm, e.g. -62
Key field: WiFi.RSSI() returns dBm where closer to zero is stronger
(≥ -57 strong, -70 moderate, -82 weak, below that may drop).
Things to know:
- RSSI jumps ±5 dBm sample-to-sample; the example smooths it with an exponential moving average so the gauge reads steadily.
WiFi.RSSI()is meaningful only while connected — guard onWiFi.status() == WL_CONNECTEDso a reconnect doesn't publish a bogus0.- One reading is all the widget needs; it computes quality %, tiers, average and the history graph itself.
- The network name (SSID) shown on the widget is filled in automatically — the library reports it to the dashboard once on every cloud connect (and reconnect) as part of the device-info report, so there's nothing to configure or publish yourself.
Memory widget
The Memory widget tracks the ESP32's heap so you can watch a long-running device's RAM trend — spot a slow leak, or confirm a refactor freed memory. It reads three datastreams, the three numbers the core exposes:
| Datastream | Value | Meaning |
|---|---|---|
free_heap | ESP.getFreeHeap() | bytes free right now |
min_free_heap | ESP.getMinFreeHeap() | low-water mark since boot (the worst dip) |
max_alloc | ESP.getMaxAllocHeap() | largest single block still allocatable |
Dashboard setup: create three datastreams (kind: number) named
"free_heap", "min_free_heap", "max_alloc", drop a Memory widget on the
canvas, and bind its three slots to them.
Sketch: publish all three every couple of seconds:
Fluxgrid.write("free_heap", ESP.getFreeHeap());
Fluxgrid.write("min_free_heap", ESP.getMinFreeHeap());
Fluxgrid.write("max_alloc", ESP.getMaxAllocHeap());
free_heap trending down while max_alloc shrinks faster is the classic
fingerprint of heap fragmentation — the same total is free, but no longer in
one block. See File → Examples → Fluxgrid → MemoryMonitor.
Memory Chart widget
The Memory Chart widget is a richer, four-style take on the Memory widget: it plots both the internal heap and external SPI PSRAM — as an area time-series, dual capacity bars, a CRT bar histogram, or animated liquid tanks (pick the look in the widget's Style property).
One line turns it on. Call Fluxgrid.reportMemory() once in setup() and the
library auto-publishes the device's memory every few seconds — no per-loop
write() calls, and no datastreams to create or bind. All the numbers ride in
a single MQTT message, and the widget just picks the device (exactly like the
Device Info widget):
void setup() {
Fluxgrid.reportMemory(); // heap + PSRAM, every 5 s
Fluxgrid.begin();
}
void loop() {
Fluxgrid.run();
}
What it publishes each tick (heap always; the PSRAM pair only on boards that have
PSRAM). The heap/PSRAM totals are read automatically from the device (the same
device_info the Device Info widget reports), so nothing else is sent:
| Value | Source | Meaning |
|---|---|---|
| free heap | ESP.getFreeHeap() | heap bytes free right now |
| min free heap | ESP.getMinFreeHeap() | heap low-water mark since boot |
| max alloc | ESP.getMaxAllocHeap() | largest single block still allocatable (fragmentation) |
| free PSRAM | ESP.getFreePsram() | PSRAM bytes free right now |
| min free PSRAM | ESP.getMinFreePsram() | PSRAM low-water mark since boot |
Dashboard setup: drop a Memory Chart widget on the canvas, and in its Properties panel pick the device — no datastreams to wire up. A board with no PSRAM just leaves that half hidden automatically.
reportMemory(intervalMs) takes an optional cadence: the default is 5 s — pass
2000 for a snappier demo, 30000+ for a long-term leak watch, or 0 to turn it
back off.
See File → Examples → Fluxgrid → MemoryChart.
Power Monitor widget
The Power Monitor widget shows bus voltage and shunt current at the same time — the two numbers an INA219 reports — plus the power, energy and charge worked out from them. It comes in four styles, picked in the widget's Style property, and each one has its own fullscreen instrument:
| Style | Tile | Fullscreen |
|---|---|---|
| Flux | digital cells over a dual-axis trace | three hero cells, big trace, window stats |
| Swiss | print sheet with rulers | poster sheet, window table, trace band |
| Phosphor | CRT terminal with ASCII meters | histogram + a live sample log |
| Vintage | two moving-coil dials in a bakelite case | full bench instrument, watt counter, working range knobs |
It binds two datastreams (kind: number) — and only two:
| Datastream | Value | Meaning |
|---|---|---|
voltage | ina219.getBusVoltage_V() | bus volts across the load |
current | ina219.getCurrent_mA() / 1000 | amps through the shunt |
Fluxgrid.write("voltage", ina219.getBusVoltage_V());
Fluxgrid.write("current", ina219.getCurrent_mA() / 1000.0f);
Publish amps, not milliamps. The widget's Current unit setting (A / mA) is
display-only — it changes how the reading is printed, not what travels on the
wire — so keeping the stream in amps is what makes power = V x A come out right.
Everything else is derived in the browser: power (W), energy (Wh), charge (mAh) and the min / max / average over the selected window. The widget keeps its own sampled history for that, which starts when the tile loads — so the Wh / mAh counters reset on a page reload, and the Vintage panel's RESET button zeroes them on demand.
Dashboard setup: drop a Power Monitor widget on the canvas (it creates both datastreams for you), pick a style, then set the Full scale and Limits to match your supply. Past a limit the reading turns red, the fullscreen prints OVER LIMIT, the Vintage OVER lamp lights and the dial's red band starts there.
Any source of the two numbers works — an INA226, an ACS712 on an ADC pin, or a plain voltage divider. See File → Examples → Fluxgrid → PowerMonitor for the INA219 wiring.
Power Monitor 3CH widget
The Power Monitor 3CH widget is the three-rail sibling of the one above: it watches three rails at once — voltage and current on each — plus the watts per rail, the total, each rail's share of the load, and the energy counters. Same four styles, each redesigned for three channels rather than scaled up from one.
It binds six datastreams (kind: number), two per rail:
| Rail | Voltage | Current |
|---|---|---|
| CH1 | ch1_v | ch1_a |
| CH2 | ch2_v | ch2_a |
| CH3 | ch3_v | ch3_a |
Fluxgrid.write("ch1_v", busVolts(0));
Fluxgrid.write("ch1_a", shuntAmps(0)); // ...and the same for CH2 / CH3
Everything else is derived in the browser — watts, the total, the share, the Wh / mAh — so nothing else needs sending. As with the single-channel widget, publish amps, not milliamps: the Current unit setting is display-only.
Two kinds of fault, set per rail in the Properties panel:
- OVER — the rail is drawing more than its current limit. That channel's amps turn red and the Vintage deck's OVER lamp lights.
- RAIL — the rail has drifted further than the tolerance window from its nominal voltage (3.30 / 5.00 / 12.00 V by default). That channel's volts turn red and the RAIL lamp lights.
Each rail also gets its own ammeter full scale, because a 3.3 V logic rail and a 12 V motor rail don't belong on the same one.
Dashboard setup: drop a Power Monitor 3CH widget (it creates all six datastreams), pick a style, then name each rail and set its nominal voltage, limit and full scale.
The example reads an INA3221 directly over I2C — no sensor library to install — and shows the register maths and the shunt-resistor scaling, which is the part that's easy to get wrong. See File → Examples → Fluxgrid → PowerMonitor3CH.
Device Info widget
The Device Info widget shows the hard facts about a board — chip model, silicon revision, core count, MAC addresses, flash / PSRAM size, SDK version, why it last reset, and its live network details. You don't publish any of this — the library gathers it and reports it automatically on every cloud connect (and reconnect), so the widget just works once you drop it on the canvas and point it at the device.
It travels on the same retained meta topic that already carries the WiFi SSID
(so the WiFi RSSI widget keeps showing the network name).
The payload is a JSON object — the SSID at the top level plus an info block:
{
"ssid": "MyWiFi",
"info": {
"chip": { "model":"ESP32-S3", "rev":"v0.2", "cores":2, "mhz":240,
"id":"ECDA3B1A2B3C", "features":["WiFi","BLE"] },
"mem": { "heap":327680, "flash":4194304, "psram":8388608 },
"mac": { "sta":"EC:DA:3B:1A:2B:3C", "ap":"…", "bt":"…" },
"fw": { "sdk":"v5.3.1", "reset":"POWERON" },
"net": { "ip":"192.168.1.42", "mask":"255.255.255.0", "gw":"192.168.1.1",
"dns":["192.168.1.1","8.8.8.8"], "rssi":-54, "ch":6,
"bssid":"AA:BB:CC:DD:EE:FF" },
// OTA readiness — see "OTA updates (flash over WiFi)". Absent on firmware
// older than 0.24.0, which the dashboard reads as "unknown", never as "no".
"ota": { "on":true, "host":"fluxgrid-a1b2c3", "port":3232,
"slots":2, "run":"app0", "next":"app1",
"avail":2064384, "used":1069056, "data":true }
}
}
The static facts (chip, mem, MACs, SDK) don't change between boots; the net
block and reset reason reflect the current connection. Fields that a given
chip can't report (e.g. psram on a board with none → 0) are still present so
the widget's layout stays stable.
Map widget
The Map widget plots a position on a Leaflet map (CARTO basemap tiles, OpenStreetMap data — no API key needed). It reads a single datastream; publish to it in either format:
"lat,lng" e.g. "37.7749,-122.4194"
{"lat":37.7749,"lng":-122.4194} JSON (note: lng, not lon)
The JSON form takes two optional extras:
acc— accuracy radius in metres. The widget draws a halo of that size instead of implying a pinpoint — ideal for coarse fixes (e.g. IP geolocation). Omit it for a precise GPS pin.label— text shown in the marker popup / coordinate line (e.g. a city).
// Coarse position with a 25 km accuracy halo and a popup label:
Fluxgrid.write("map", "{\"lat\":24.06,\"lng\":120.55,\"acc\":25000,\"label\":\"Taichung\"}");
Each new position is appended to a track (the polyline) so movement draws a path.
Two ready-to-flash examples live under File → Examples → Fluxgrid → Map:
- IpLocation — no hardware; locates the device from its public IP (via ip-api.com) and reports it with an honest accuracy circle.
- Gps — reads a real GPS module (e.g. u-blox NEO-6M / NEO-M8N) over UART on an ESP32-S3 and plots a precise live fix. Needs the TinyGPS++ library (by Mikal Hart) from the Library Manager.
Control widgets
Control widgets send values down to the device. Drop one on the canvas, note
the datastream handle it creates, and read it with Fluxgrid.read("handle") or
an onReceive("handle", …) callback. Each has a ready-to-flash example under
File → Examples → Fluxgrid.
| Widget | Sends | Read it as | Example |
|---|---|---|---|
| Switch | 1 / 0 on toggle (latching) | .asBool() | Switch → SwitchRelay |
| Icon Tile | 1 / 0 on tap (latching) | .asBool() | IconTile → IconTileLamp |
| Button | 1 while held, 0 on release (momentary) | .asBool() | Button → ButtonOnReceive |
| Slider | a number between the datastream's min / max | .asInt() / .asFloat() | Slider → SliderDimmer |
| Stepper | the new absolute value (−/+ by the step) | .asInt() / .asFloat() | Stepper → StepperSetpoint |
| Segmented | the selected option index (0, 1, 2, …) | .asInt() | Segmented → SegmentedMode |
| RGB | red / green / blue (0–255) on three datastreams | .asInt() per channel | RGB → RgbStrip |
| Switch Group | 1 / 0 per row, each on its own datastream | .asBool() per handle | SwitchGroup → SwitchGroupRoom |
| Piano | a MIDI note on press · a recorded take on SEND (two datastreams) | see Piano widget | Piano → PianoBuzzer |
| Jukebox | a whole song on play (two datastreams) | see Jukebox widget | Jukebox → JukeboxBuzzer |
Notes:
-
A Switch and an Icon Tile both latch — they stay on/off until tapped again; a Button is momentary (only "on" while held). Pick by feel.
-
The Segmented widget sends the option index, not its label — option 0 is the first entry. Set the labels in the widget's Properties (
Off,Low,High). -
The RGB widget edits one colour but writes its three channels separately, so you receive one message per channel (see RgbStrip). Want the colour in a single message instead? Use the Color widget — it packs the colour into one 24-bit integer (
0xRRGGBB) on one datastream, which you unpack on the device:int rgb = Fluxgrid.read("color").asInt(); int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF; -
The Switch Group is several relays grouped into one card; handle each row's datastream as you would a single Switch. The card's master toggle flips every row, so your per-row handlers cover it for free. Rename rows and set per-row icons in the widget's Properties.
Three ways to handle an incoming value
An incoming value is not a pin. It arrives from the cloud whenever someone taps
the widget, and Fluxgrid.run() is what receives it and hands it to you. So the
only question is where you want to be standing when it lands.
1 — onReceive() with a lambda. The usual one: shortest for a handler of a
line or two.
#define SWITCH_HANDLE "relay" // the Switch widget's datastream handle
void setup() {
Serial.begin(115200);
// Fires once per toggle, with the new state.
Fluxgrid.onReceive(SWITCH_HANDLE, [](FluxValue v) {
if (v.asBool()) Serial.println("Switch: ON");
else Serial.println("Switch: OFF");
});
Fluxgrid.begin(); // register the callback BEFORE begin()
}
void loop() {
Fluxgrid.run(); // delivers the message, calls your lambda
}
2 — onReceive() with a named function. The same call with a plain function
instead of a lambda. Easier to read once the handler grows past a few lines, and
you can point more than one handle at it.
#define RELAY_PIN 2
void onSwitch(FluxValue v) { // must take a FluxValue and return void
digitalWrite(RELAY_PIN, v.asBool() ? HIGH : LOW);
Fluxgrid.println(v.asBool() ? "relay on" : "relay off");
}
void setup() {
Fluxgrid.onReceive(SWITCH_HANDLE, onSwitch); // no () — you pass the function
Fluxgrid.begin();
}
3 — Polling read() in loop(). read() gives you the latest value at any
time, so you can do it by hand. But loop() runs thousands of times a second,
and read() tells you what the value is, never that it just changed — so to
get one line per toggle you have to remember the previous value and compare:
bool last = false;
void loop() {
Fluxgrid.run();
bool now = Fluxgrid.read(SWITCH_HANDLE).asBool();
if (now != last) { // the edge detection onReceive does for you
last = now;
if (now) Serial.println("Switch: ON");
else Serial.println("Switch: OFF");
}
}
That version is correct, but it is onReceive() rewritten by hand — a state
variable, a comparison and an assignment for every widget you add. Drop the if
and you print thousands of identical lines a second, which is the mistake that
sends most people back to this page.
Which to reach for:
| What you want | Use |
|---|---|
| To do something once, when the control is tapped — print a line, count a press, start a motion, send a command | onReceive() |
| The current state, every loop — hold a relay, an LED or a PWM output at whatever the widget says | read() |
Driving an output is the case where polling is the simpler code, because "the relay should be whatever the Switch says" needs no memory of the last value:
void loop() {
Fluxgrid.run();
digitalWrite(RELAY_PIN, Fluxgrid.read(SWITCH_HANDLE).asBool()); // no `last`
}
Things to watch:
- Register before
begin(). When the board comes online the cloud replays the last value of every latching control bound to it, so your callback fires once shortly afterbegin()— that is the dashboard re-syncing you, not a stray write. Register it insetup()abovebegin()and the replay finds it. Momentary controls (Button, Piano key, joystick) are events, not state, and are never replayed. - One callback per handle. A second
onReceive()on the same handle replaces the first, it does not chain. Two widgets means two handles and two callbacks. - The callback runs inside
run(). It is called on yourloop()task whileFluxgrid.run()is delivering the message — soloop()must keep callingrun(), and the callback must return quickly. Adelay(), awhilethat waits for a sensor, or a blocking HTTP request inside it stalls the MQTT link and the board can read offline. For anything slow, set a flag in the callback and do the work inloop(). read()never touches the network. It reads a local cache thatrun()fills, so it is cheap to call every loop — but it only has whatrun()has already received.- An empty value reads as
false.read()is empty until the first value arrives, so a poller that starts atlast = falsecannot tell "off" from "nothing received yet".Fluxgrid.has(handle)andv.isEmpty()tell the two apart.
Piano widget
The Piano is a playable keyboard that drives a passive buzzer. Unlike the other controls it binds two datastreams, so the example reacts to both:
| Datastream | Kind | Carries | When |
|---|---|---|---|
note | Number | the live MIDI note (0–127, 0 = silence) | the instant a key is pressed/released |
seq | Text | the recorded take as one mseq.v1 JSON string | once, when you tap SEND |
Live play. Each key press writes its MIDI note to note; play it on a passive
buzzer with tone() (a passive buzzer needs an AC waveform, so plain
digitalWrite() won't do). The buzzer is monophonic, so the widget applies
last-note priority — the newest held note wins, and 0 silences it:
static unsigned int midiToFreq(int m) { return lroundf(440.0f * powf(2.0f, (m - 69) / 12.0f)); }
Fluxgrid.onReceive("note", [](FluxValue v) {
int m = v.asInt();
if (m > 0) tone(BUZZER, midiToFreq(m)); // 60 = C4, 69 = A4 = 440 Hz
else noTone(BUZZER);
});
Record → Send. Tap REC (a 3-2-1 count-in, then up to 30 s), play, then
SEND. The whole take — notes and timing — arrives on seq as one JSON
message:
{ "pin": "seq", "fmt": "mseq.v1", "dur": 4200, "count": 12,
"events": [[60,1,0],[60,0,180],[62,1,210],[62,0,400]] }
events is [midiNote, on(1)/off(0), msOffsetFromStart]. Parse it with
ArduinoJson and schedule each event by its offset so the buzzer replays the
performance — non-blocking, so Fluxgrid.run() keeps the link alive. See the
full Piano → PianoBuzzer example for the scheduler.
Keep takes short. The recording is sent as one MQTT message, so the payload must fit the MQTT buffer (2 KB by default — a busy 30 s can exceed it). Raise it with
#define FLUXGRID_MQTT_BUFFER 1024*8before the include if you need more. A local synth in the browser previews the notes; the real sound is the buzzer.
Jukebox widget
The Jukebox plays whole songs on a passive buzzer — a retro song box whose library is the wonderful robsoncouto/arduino-songs project (credit: Robson Couto). Like the Piano it binds two datastreams:
| Datastream | Kind | Carries | When |
|---|---|---|---|
song | Text | the whole tune as one jbq.v1 JSON string | when you press play |
play | Boolean | 1 play / 0 stop | on the transport buttons |
The song payload is the tune's notes — notes + tempo, nothing else:
{ "fmt": "jbq.v1", "tempo": 120, "m": [60,8, 62,8, 64,4, 0,8, 67,-4] }
m is a flat [midi, divider, …] list: midi is the note (0 = rest), divider
the length (4 = quarter, 8 = eighth, negative = dotted ×1.5) — the same
convention as the Arduino song sketches. Note length in ms is
(60000*4 / tempo) / divider. Convert each note to a tone() and schedule it
non-blocking; see the Jukebox → JukeboxBuzzer example for the player.
Songs are big — raise the MQTT buffer. A whole song streams in one message, and the longest ones serialize to ~3–4 KB, past the default 2 KB. The example sets
#define FLUXGRID_MQTT_BUFFER 1024*8(8 KB) before the include so every song fits. See Bigger payloads.
Bigger payloads (FLUXGRID_MQTT_BUFFER)
The library keeps one MQTT buffer that must hold the whole packet (topic + framing + payload) — both for what you publish and what you receive. It defaults to 2048 bytes, which covers telemetry, control writes and a ~16-network WiFi scan. When a widget sends or receives something bigger (the Jukebox streams an entire song), raise it before the include:
#define FLUXGRID_MQTT_BUFFER 1024*8 // 8 KB
#include <Fluxgrid.h>
It costs that many bytes of RAM (negligible on an ESP32) and is applied for you
in begin().
Online / offline detection (FLUXGRID_MQTT_KEEPALIVE)
Every widget bound to a device carries a small live dot in its top-right corner: green when the device is online, grey when it isn't. You write no code for it. This section is about what's behind it — and, more usefully, how long it takes to turn grey.
The three signals
Presence isn't one mechanism but three, and only the first is fast:
| Signal | Sent by | Catches |
|---|---|---|
| Last-Will | the broker, on the device's behalf | the device stopped answering — power cut, crash, WiFi gone |
retained online heartbeat, every 30s | the library, from Fluxgrid.run() | keeps a device that publishes little or nothing (a relay-only board) correctly marked online |
| staleness sweep, 120s window | the Fluxgrid server | the rare case the Last-Will never arrives at all (e.g. the broker restarted) |
The Last-Will is the one that does the work. At connect time the device leaves it
with the broker — if I disappear, publish offline on my status topic for me.
A board that loses power can't say goodbye, so the broker's only evidence is
silence: once 1.5 × the keep-alive passes with nothing heard from that
client, it declares it gone and publishes the will. That message is what greys
the dot.
The other two are backstops, and they are slow on purpose. Don't design around them — if a device drops and the dot takes minutes rather than seconds, what failed is the Last-Will, not the timer.
How fast
The keep-alive sets the deadline, so it sets the detection time:
FLUXGRID_MQTT_KEEPALIVE | Dashboard greys out after | loop() may block for |
|---|---|---|
| 5s | ~8s | 7.5s |
| 10s | ~15s | 15s |
| 15s (default) | ~22s | 22s |
| 30s | ~45s | 45s |
| 60s | ~90s | 90s |
Pulling the power on a board with the default settings greys its widgets in about 20 seconds.
What counts as "activity"
Any packet in either direction resets the clock — a write(), a control message
arriving, the 30s heartbeat. A board that publishes a reading every second
therefore never sends a keep-alive ping at all; the ping only fires when the
device has been genuinely idle.
Which is why the third column of that table is the one to read carefully. The
keep-alive is not a network-latency budget — it's how long your loop() may go
without calling Fluxgrid.run(). From the broker's side, a device busy inside
a long delay() and a device with its power cable pulled look identical.
Changing it
Set it before the include, like the buffer size:
#define FLUXGRID_MQTT_KEEPALIVE 30 // tolerate ~45s of blocked loop()
#include <Fluxgrid.h>
Raise it if your sketch can stall longer than the default 22s — a long
delay(), a slow blocking sensor, an HTTP request to a sluggish API, a big SD
card write. Otherwise the broker drops a device that was merely busy, and it has
to reconnect (a fresh TLS handshake, and a spurious offline → online pair in the
device's activity log).
Lowering it buys less than it looks. Going from 15s to 5s gains you fourteen
seconds of detection speed and costs two thirds of your stall tolerance — 7.5s is
inside what an ordinary delay(10000), a blocking scanWiFi() plus a bit of
work, or a WiFi roam between access points will spend. The failure mode isn't a
slightly-late dot, it's a flickering one: false offline, reconnect, false
online, repeated, which also fills the device's uptime history with disconnects
that never happened. A dot that flaps is worse than a dot that's fifteen seconds
late, because you stop believing either one.
If you want to try a shorter value, put it in one sketch, leave it for a day, and check the device's activity view for disconnects you can't account for. That answers it for your network far better than any number here.
Display widgets — the Card
Display widgets go the other way: the device write()s a value up and the
widget renders it. The firmware is the same one line you already know —
Fluxgrid.write("handle", value) — so what's worth knowing is how to dress the
value up on the dashboard.
The Card is the most configurable of these: a tinted icon next to a big reading, with an optional label and a prefix/postfix around the value. A few things specific to it:
- It can show text, not just numbers. Bind a Card to a
kind: textdatastream andwrite()a string ("OK","FAULT") — most widgets can't do this. On a text Card the conditional colour compares with contains / equals instead of>/<. - Unit size controls the prefix/postfix: Small (default) keeps
%/$as a compact affix; Normal matches the value's size for units like°Cthat read as part of the number. - Conditional icon colour recolours the icon when the value meets a
condition (e.g.
> 30→ red). - Templates — an admin can save a Card's whole look under a name (Temperature, Humidity, …) so anyone can apply it to a new Card in one click.
Three ready-to-flash examples under File → Examples → Fluxgrid → Card:
| Example | Datastream | Shows |
|---|---|---|
| TemperatureCard | number | postfix °C, Unit size Normal, colour when too hot |
| BatteryCard | number | postfix %, Unit size Small (the default affix) |
| StatusCard | text | a status word (OK / WARN / FAULT), colour on FAULT |
How it maps to your dashboard
Fluxgrid.write("temp", x)→ any Gauge / Value / Chart / Card bound to thetempdatastream updates live.- A Switch / Slider / Button → read it with
Fluxgrid.read("relay")or anonReceive("relay", …)handler on the device — see Three ways to handle an incoming value for which of the two to reach for. - Stateful controls survive a reboot. When the cloud sees the board come
online it re-sends the last Switch / Slider / colour value, so your
onReceive()fires once per bound control shortly afterbegin()— that is the dashboard re-syncing you, not a stray write. Momentary controls (Button, joystick) are events, not state, and are never replayed. Empty writes are ignored, so a control never briefly reads as0before its real value lands. - The device shows online/offline automatically, so widgets grey out when it
drops — a live dot in each widget's top-right corner. Presence uses the MQTT
Last-Will for the offline edge plus a retained
onlineheartbeat the library republishes every 30s, so a board that sends little or no telemetry still reads as online. CallFluxgrid.run()regularly inloop()for it to fire. Pulling the power greys the dashboard in about 20 seconds; see Online / offline detection for why, and for the one knob that changes it. - The library reports its version to the dashboard: the presence payload is
online <version>(e.g.online 0.9.3), so the device list can show which firmware each board runs and flag out-of-date ones. The same version is printed to the Serial Monitor on boot ([Fluxgrid] Fluxgrid library v0.9.3). - The library also reports a device-info snapshot once per connect on a
dedicated retained
metatopic — the WiFi network name (SSID) plus chip, memory, MAC, firmware and live-network facts. The WiFi RSSI widget reads the SSID from it, and the Device Info widget shows the rest, all without any extra code.
Under the hood it speaks the Fluxgrid topic scheme so you don't have to:
fluxgrid/<token>/v/<pin> telemetry up
fluxgrid/<token>/w/<pin> control writes down
fluxgrid/<token>/wg/<group> grouped control write — one JSON packet, e.g. {"x":1,"y":-1}
fluxgrid/<token>/status online[ <version>] / offline (retained)
fluxgrid/<token>/meta device info {"ssid":…,"info":{chip,mem,mac,fw,net}} (retained, on connect)
fluxgrid/<token>/fs/req file-explorer commands down (when enableFiles())
fluxgrid/<token>/fs/res file-explorer results up (when enableFiles())
Multi-variable control widgets (joystick, RGB picker, range slider) send all of
their values in one wg/<group> packet so they arrive together (no torn
half-updates) instead of one message per value. The library unpacks it for you
and delivers each field to its own handle — so you still just
read("x") / read("y") (or onReceive), exactly as for any other value.
Security note
Each device has its own MQTT account and a broker ACL that locks it to its
own fluxgrid/<token>/# topic subtree — one device cannot read or write
another's topics. Those per-device credentials are bundled into the single
FG_TOKEN string (<token>.<user>.<pass>) and split by the library at
begin().
TLS is on by default (secure(true)), so the link is always encrypted. Out of
the box the server certificate is not verified — friendly for getting
started, but an active attacker who can redirect your traffic could impersonate
the broker. For production devices, pin your broker's root CA:
static const char ROOT_CA[] = R"PEM(
-----BEGIN CERTIFICATE-----
...your broker's root CA certificate...
-----END CERTIFICATE-----
)PEM";
void setup() {
Fluxgrid.setCACert(ROOT_CA); // before begin()
Fluxgrid.begin();
}
With a pinned CA the device refuses to connect to anything that can't present a certificate signed by it. (The PEM string must stay alive — a global, as above, is the usual way.)
MIT License · Lonely Binary