Перейти к содержимому

Build a Standalone Arduino Energy Monitor with rbAmp — Watts on an OLED, No Network

30 июля 2026 г. от
Build a Standalone Arduino Energy Monitor with rbAmp — Watts on an OLED, No Network
Administrator

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:

plaintext
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:

plaintext
arduino-cli lib install RbAmp

You'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:

cpp
/**
 * 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

cpp
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

cpp
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 miss

These 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

cpp
RbAmpPeriodSnapshot snap;
if (!dev.readPeriodSnapshot(snap)) { /* stale/not-ready: report + skip */ return; }
// dev.energy().wh(0) has now advanced for this window

Energy 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()

cpp
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 s

Two 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:

plaintext
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_session alongside 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_MS down for a livelier graph, or ENERGY_MS up 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]


Documentation & source code
📖 rbAmp Arduino library reference · 💻 rb-amp/rbamp-arduino on GitHub — issues, examples, ⭐
Three-Phase Energy Monitoring in Home Assistant: 3× rbAmp UI on One ESP32
Coherent per-phase real power from three UI modules on one I²C bus — the full build