Not every energy project needs Home Assistant, a cloud, or even Wi-Fi. Sometimes you just want a little box on the bench that says 230 V · 0.21 A · 48 W · 0.97 PF on a screen, updating every second — a real meter you fully own, with no network in sight. That's this build: an Arduino, one rbAmp module, a current clamp, and a tiny OLED. It's also the gentlest on-ramp to the rbAmp Arduino library — everything you learn here carries into the bigger Arduino projects (multi-module metering, load alerts, data logging) later in this series.
What you'll build
- Live voltage, current, real power, and power factor on a 128×64 OLED, refreshing about once a second.
- Accumulated energy (Wh) on the same screen — real watt-hours, integrated on the host over wall-clock time.
- Everything over a single I²C bus shared by the rbAmp module and the display — four signal wires total.
- No Wi-Fi, no cloud, no HA. Serial mirror for debugging; the OLED is the whole UI.
Bill of materials
| Item | Qty | Notes |
|---|---|---|
| rbAmp Basic Wattmeter (UI1) | 1 | voltage + current + on-chip real power · product |
| SCT-013 current transformer | 1 | rating to match your circuit · product |
| ESP32 dev board (Arduino framework) | 1 | the compile-verified target — an ESP32 gives honest long-run Wh (true 64-bit doubles) and RAM headroom for the display |
| SSD1306 128×64 I²C OLED | 1 | the display (I²C address 0x3C, shares the bus) |
| Jumper wires | — | the shared I²C bus (SDA / SCL / VCC / GND) |
Any Arduino-compatible board works. The sketch is plain Arduino C++ — it runs on an Uno, a Nano, an STM32, an RP2040, or an ESP32. We used an ESP32 here because it's cheap, everywhere, and has the headroom for honest long-run energy totals. The one thing that changes per board is the I²C bus speed: an ESP32 wants
Wire.setClock(50000)(50 kHz, its driver's setting), while most non-ESP32 boards run the module at 100 kHz.
Wire it up
The rbAmp module and the OLED both live on the same two-wire I²C bus — they just have different addresses
(0x50 for rbAmp, 0x3C for the OLED), so they coexist without conflict:
Arduino rbAmp (0x50) OLED (0x3C)
------- ------------ -----------
SDA ───────────────┬────── SDA ───────┬─────── SDA
SCL ───────────────┼────── SCL ───────┼─────── SCL
5V ───────────────┼────── VCC └─(3.3V) VCC *(check your OLED's voltage)*
GND ───────────────┴────── GND ──────────────── GND
│
└── SCT-013 clamped around ONE mains conductor (arrow → load)- The bus runs at 3.3 V logic (the module's I²C lines are 5 V-tolerant but idle at the host's 3.3 V). The rbAmp module carries on-board 4.7 kΩ pull-ups to 3.3 V, which serve this single-module-plus-OLED bus — you don't enable the ESP32's weak internal ones. Note the levels: rbAmp VCC = 5 V, OLED VCC = 3.3 V, common ground — the I²C bus itself is 3.3 V logic. (Why is rbAmp 5 V-powered but a 3.3 V bus?)
- The SCT-013 clamps around an insulated conductor — you never cut or strip live wire for the current side. The AC voltage side of the module connects to mains; if you're not comfortable near live conductors, have a qualified person make that connection. Mains is dangerous — treat it with respect.
Install the library
Arduino IDE: Sketch → Include Library → Manage Libraries…, search RbAmp, install. Or with arduino-cli:
arduino-cli lib install RbAmpYou'll also need the OLED driver — install Adafruit SSD1306 (v2.5.14) from the Library Manager; it pulls in Adafruit GFX and Adafruit BusIO automatically.
The sketch
Copy this in, set MY_CT to match your clamp, and flash. It's built for an ESP32 (Arduino framework) at
50 kHz (the ESP-IDF I²C driver's setting — a non-ESP32 host like an Uno/Nano would use 100 kHz), with an
Adafruit SSD1306 OLED:
/**
* StandaloneMonitor.ino — a standalone Arduino energy monitor (no network, no HA).
*
* One rbAmp UI1 module + an ESP32 + a 128x64 SSD1306 I2C OLED on the same two-wire
* bus. Shows Voltage / Current / Power / Power Factor live (~1 s) and a
* host-integrated Energy (Wh) total. Wh is integrated on the master from avg power
* x wall-clock dt — that's what makes the total honest.
*
* Board: ESP32 (Arduino framework) -- esp32:esp32:esp32
* Bus: I2C @ 50 kHz (Wire.setClock(50000)) -- required on ESP32-Arduino (its
* Wire wraps the ESP-IDF i2c driver). On a non-ESP32 host use 100 kHz.
* OLED: Adafruit_SSD1306 + Adafruit_GFX (addr 0x3C, shares SDA/SCL with rbAmp).
*
* Wiring:
* SDA -> GPIO21 (both devices)
* SCL -> GPIO22 (both devices)
* rbAmp VCC -> 5 V (4.5-5.5 V; below 4.5 V the ADC loses accuracy)
* OLED VCC -> 3.3 V
* GND -> GND (common ground, both devices)
* ~4.7 kOhm pull-ups on SDA and SCL to 3.3 V (host logic level)
* OLED 0x3C, rbAmp 0x50
*
* Accuracy: +/-0.5% of reading per channel with the matched rbAmp CT.
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <RbAmp.h>
/* ---- configuration -------------------------------------------------------- */
static const uint8_t RBAMP_ADDR = 0x50;
static const uint8_t OLED_ADDR = 0x3C;
static const uint16_t OLED_W = 128;
static const uint16_t OLED_H = 64;
static const uint32_t I2C_HZ = 50000; /* ESP32-Arduino: 50 kHz */
/* Match this to your clamp: Sct013_005 / _010 / _030 / _050 / _020. */
static const RbAmpCTModel MY_CT = RbAmpCTModel::Sct013_030;
static const uint32_t LIVE_MS = 1000; /* refresh V/I/P/PF each second */
static const uint32_t ENERGY_MS = 30000; /* latch a period + roll Wh every 30 s */
/* ---- objects -------------------------------------------------------------- */
static Adafruit_SSD1306 oled(OLED_W, OLED_H, &Wire, -1);
static RbAmp dev(Wire, RBAMP_ADDR, RbAmpTopology::Single);
static bool oled_ok = false;
static uint32_t next_live_ms = 0;
static uint32_t next_energy_ms = 0;
/* Last-good live values: a single transient read hiccup should not blank the
* display, so we hold the previous plausible reading. */
static float lastV = NAN, lastI = NAN, lastP = NAN, lastPF = NAN;
static uint32_t reads_ok = 0, reads_bad = 0;
/* ---- helpers -------------------------------------------------------------- */
static void banner(const __FlashStringHelper* l1, const char* l2) {
if (!oled_ok) return;
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 0);
oled.println(l1);
oled.println(l2);
oled.display();
}
/* Apply the CT model only if the module isn't already on it (setCTModel()
* persists to flash, so we avoid rewriting the same value every boot). */
static void ensureCtModel() {
uint8_t applied = 0;
if (dev.readCTModelCh(0, applied) && applied == static_cast<uint8_t>(MY_CT)) {
Serial.println(F("CT model already set; skipping flash write"));
return;
}
Serial.println(F("configuring CT model (persists to flash)..."));
if (!dev.setSensorClass(RbAmpSensorClass::Sct013) || !dev.setCTModel(MY_CT)) {
Serial.print(F("CT config failed: "));
Serial.println(RbAmp::errorString(dev.lastError()));
}
}
static void drawLive() {
if (!oled_ok) return;
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
/* Power headline, big. */
oled.setTextSize(2);
oled.setCursor(0, 0);
oled.print(isnan(lastP) ? 0.0f : lastP, 0);
oled.println(F(" W"));
/* V / I / PF row. */
oled.setTextSize(1);
oled.setCursor(0, 20);
oled.print(isnan(lastV) ? 0.0f : lastV, 1); oled.print(F("V "));
oled.print(isnan(lastI) ? 0.0f : lastI, 3); oled.print(F("A PF"));
oled.println(isnan(lastPF) ? 0.0f : lastPF, 2);
/* Energy total. */
oled.setCursor(0, 36);
oled.print(F("Energy: "));
oled.print(dev.energy().wh(0), 3);
oled.println(F(" Wh"));
/* Health line. */
oled.setCursor(0, 52);
oled.print(F("ok=")); oled.print(reads_ok);
oled.print(F(" bad=")); oled.print(reads_bad);
oled.display();
}
static void readLive() {
float v = dev.readVoltage();
float i = dev.readCurrent(0);
float p = dev.readPower(0);
float pf = dev.readPowerFactor(0);
if (!isnan(v)) lastV = v;
if (!isnan(i)) lastI = i;
if (!isnan(p)) lastP = p;
if (!isnan(pf)) lastPF = pf;
if (isnan(v) && isnan(i) && isnan(p)) reads_bad++; else reads_ok++;
Serial.print(F("V=")); Serial.print(lastV, 1);
Serial.print(F(" I=")); Serial.print(lastI, 3);
Serial.print(F(" P=")); Serial.print(lastP, 1);
Serial.print(F(" PF=")); Serial.print(lastPF, 2);
Serial.print(F(" Wh=")); Serial.println(dev.energy().wh(0), 3);
drawLive();
}
/* Latch a metering period; the library rolls Wh from avg power x master dt on a
* successful, valid snapshot. A stale/not-ready period is reported and skipped
* so the display keeps running on the last live values. */
static void rollEnergy() {
RbAmpPeriodSnapshot snap;
if (!dev.readPeriodSnapshot(snap)) {
Serial.print(F("period skipped: "));
Serial.println(RbAmp::errorString(dev.lastError()));
return;
}
Serial.print(F("period ok: avg_P0="));
Serial.print(snap.avg_p[0], 2);
Serial.print(F("W dt="));
Serial.print(snap.master_dt_ms);
Serial.print(F("ms -> Wh="));
Serial.println(dev.energy().wh(0), 4);
}
/* ---- lifecycle ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 2000) {}
Wire.begin();
Wire.setClock(I2C_HZ);
oled_ok = oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
if (oled_ok) {
banner(F("rbAmp monitor"), "booting...");
} else {
Serial.println(F("OLED init failed - running headless on Serial"));
}
dev.setLogStream(&Serial);
uint8_t tries = 0;
while (!dev.begin()) {
const char* why = RbAmp::errorString(dev.lastError());
Serial.print(F("rbAmp begin failed: "));
Serial.println(why);
banner(F("no rbAmp - retry"), why);
if (++tries % 5 == 0)
Serial.println(F("check wiring: SDA/SCL/power/GND + pull-ups"));
delay(1000);
}
Serial.println(F("rbAmp online"));
ensureCtModel();
uint32_t now = millis();
next_live_ms = now;
next_energy_ms = now + ENERGY_MS;
}
void loop() {
uint32_t now = millis();
if (static_cast<int32_t>(now - next_live_ms) >= 0) {
next_live_ms = now + LIVE_MS;
readLive();
}
if (static_cast<int32_t>(now - next_energy_ms) >= 0) {
next_energy_ms = now + ENERGY_MS;
rollEnergy();
}
delay(5);
}(Compile-verified on ESP32 with Arduino-CLI — Adafruit SSD1306 v2.5.14 + Adafruit GFX. setCTModel() writes to
flash, so the sketch only re-writes the CT preset when it actually changes — no flash wear on every boot.)
What the sketch is doing
The whole rbAmp Arduino library shows up in a handful of calls. Two functions do the real work — readLive()
every second and rollEnergy() every 30 seconds — driven by a two-cadence loop(). Here's each piece.
Setup — bind, probe, configure
RbAmp dev(Wire, 0x50, RbAmpTopology::Single); // bus, address, topology
...
while (!dev.begin()) { /* print errorString(), retry */ }
ensureCtModel();RbAmp dev(Wire, 0x50, RbAmpTopology::Single) binds the library to the I²C bus and the module at 0x50; Single
says it's a one-channel UI1 (a UI2/UI3 would pass SplitPhase / ThreePhase). begin() probes the module and
reads its capabilities — loop on it until it returns true, printing RbAmp::errorString(dev.lastError()) so a
wiring slip is obvious. ensureCtModel() reads the module's stored CT preset and only writes it if it differs
(setCTModel() persists to flash) — that avoids wearing the flash on every boot.
readLive() — the once-a-second reading
float v = dev.readVoltage();
float i = dev.readCurrent(0);
float p = dev.readPower(0);
float pf = dev.readPowerFactor(0);
if (!isnan(v)) lastV = v; // hold last-good on a transient missThese four reads pull the module's real measurements: readPower(0) is true active power in watts — the
mean of instantaneous voltage × current, not current × an assumed voltage. Any read can occasionally return NaN
(a transient bus hiccup); the sketch keeps the previous good value (lastV, lastI, …) so the display never
blinks to zero, and bumps an ok=/bad= counter you can watch on-screen. It then calls drawLive() to paint the
OLED.
rollEnergy() — the honest energy total
RbAmpPeriodSnapshot snap;
if (!dev.readPeriodSnapshot(snap)) { /* stale/not-ready: report + skip */ return; }
// dev.energy().wh(0) has now advanced for this windowEnergy works differently from the live reads. readPeriodSnapshot() sends the module an atomic LATCH — "close
the current measurement window now" — waits the settle, and reads back that window's average power. The library
then rolls the host-side watt-hour total: energy = average_power × elapsed_wall-clock_time. Because the host
owns the integration interval, there's no on-device counter to quantize or drift — dev.energy().wh(0) is an
honest running total. If a period comes back stale or not-ready, the function reports it and returns; the display
keeps running on the last live values.
The two-cadence loop()
if (now - next_live_ms >= 0) { next_live_ms += LIVE_MS; readLive(); } // 1 s
if (now - next_energy_ms >= 0) { next_energy_ms += ENERGY_MS; rollEnergy(); } // 30 sTwo independent timers: the live display refreshes every LIVE_MS (1 s) for a responsive readout, while the
heavier energy latch runs only every ENERGY_MS (30 s). Decoupling them keeps the screen snappy without
latching the module 60 times a minute. Change either constant to taste.
What you'll see
On the OLED, a live readout that tracks the load — switch on a lamp or a kettle and watch the watts jump. On the Serial Monitor (115200 baud), the same values scroll for debugging:
V=230.4 I=0.211 P=48.2 PF=0.97 Wh=12.345[screenshot / photo — media slot: the OLED showing a live reading on the bench]
How accurate is it?
With the matched rbAmp CT, each channel is rated ±0.5% of reading across the calibrated range (after calibration; accuracy widens near the very low end). The module measures true active power with per-channel phase compensation — so reactive and low-power-factor loads read honestly, not as inflated apparent power. For the why-behind-the-numbers, see how CT energy measurement really works.
Make it your own
A few small additions, once it's running:
- A resettable trip meter — keep your own
double kwh_sessionalongside the lifetime total, and zero it from a push-button on a spare GPIO for a "since I pressed it" reading. - Show more — the module also reports frequency; add a min/max power tracker, or a kWh line (
wh / 1000.0). - A soft alarm — flash the OLED or blink an LED when power crosses a threshold (no relay needed for a display-only warning).
- Send it out — you're on an ESP32, so you have WiFi: publish the readings over MQTT to your own broker, or serve a tiny web page — without giving up the local OLED.
- Slower or faster — bump
LIVE_MSdown for a livelier graph, orENERGY_MSup if a 30 s energy step is fine.
None of these touch the measurement — they're all just what you do with the numbers readLive() and
rollEnergy() already hand you.
Where to go next
This one module on a screen is the foundation. From here the Arduino series builds up:
- Meter several circuits at once — multiple rbAmp modules on the same bus, read and totalled by one Arduino.
- Act on the reading — trip a relay or sound a buzzer when a circuit goes over a current limit, all locally, no network.
- Log it — write periodic energy to an SD card or stream CSV to a PC.
(Prefer it in Home Assistant instead? The same module drops into ESPHome in 15 minutes.)
[newsletter / early-adopter subscribe block — deploy session inserts the site's standard subscribe snippet]