Skip to Content

Log Your Energy Data to an SD Card with Arduino — Timestamped CSV, No Cloud

30 de julho de 2026 by
Log Your Energy Data to an SD Card with Arduino — Timestamped CSV, No Cloud
Administrator

The monitors show it, the relay acts on it — this one keeps it. An rbAmp module, an Arduino, and a microSD card make a standalone data logger: timestamped voltage, current, power and kilowatt-hours written to the card as CSV, and streamed to your PC over serial at the same time. No Wi-Fi, no cloud, no database service — just a file you own, ready to open in a spreadsheet. It's the fourth and final build in the Arduino series (after the monitor, the multi-module bus, and the relay).

What you'll build

  • A CSV row per intervaltimestamp, voltage, current, power, energy — appended to a file on a microSD card.
  • The same rows streamed over serial, so you can log straight to a PC (or a serial plotter) without the card.
  • Real timestamps from an optional DS3231 RTC on the I²C bus — or millis() uptime if you skip the RTC.
  • Robust to a missing card or a bad read — it keeps going and flags the problem rather than hanging.

Bill of materials

Item Qty Notes
rbAmp Basic Wattmeter (UI1) 1 voltage + current + on-chip real power · product
SCT-013 CT 1 rating to match your circuit · product
ESP32 dev board (Arduino framework) 1 the compile-verified target (portable to other Arduino boards)
microSD card module 1 SPI; a FAT-formatted card
DS3231 RTC (optional) 1 real wall-clock timestamps; shares the I²C bus (address 0x68). Needs the RTClib library (Adafruit). Skip it and rows are stamped with uptime instead.

Wire it up

Two buses, no conflict:

  • I²C carries the rbAmp module (0x50) — and the RTC (0x68) if you fit one — on SDA/SCL. Power the module at 5 V, common ground; the bus is 3.3 V logic (why?).
  • SPI carries the microSD module on the ESP32's hardware VSPI pins: SCK → GPIO18, MISO → GPIO19, MOSI → GPIO23, CS → GPIO5. Keep the two buses on their own pins — no conflict with I²C.

The SCT-013 clamps around the circuit conductor — non-invasive on the current side.

The CSV

Each interval the logger appends one row; the header is written when the file is first created:

plaintext
timestamp,voltage_V,current_A,power_W,energy_Wh
2026-07-30 14:05:00,230.4,0.211,48.2,12.345
2026-07-30 14:05:30,230.1,0.208,47.6,12.477

(With the RTC fitted you get real dates; without it, the timestamp column reads up+<seconds> uptime.)

Open it in any spreadsheet or plotting tool — it's just text.

The sketch

Set MY_CT, drop in a microSD card, and flash. ESP32 (Arduino framework) at 50 kHz; SD on the VSPI pins, DS3231 RTC on the shared I²C bus:

cpp
/**
 * SdCsvLogger.ino — log energy to microSD as CSV (standalone, no network).
 *
 * One rbAmp module meters a circuit; every interval the Arduino writes a
 * timestamped CSV row to a microSD card AND mirrors it over Serial (redirect to
 * a file, or watch in a serial plotter). A data logger you own — no HA, no cloud.
 *
 * Columns: timestamp,voltage_V,current_A,power_W,energy_Wh
 *
 * Board:  ESP32 (Arduino framework); I2C @ 50 kHz. Non-ESP32 host = 100 kHz.
 * Timestamps: a DS3231 RTC on the SAME I2C bus (addr 0x68, coexists with rbAmp
 *             @0x50) gives real wall-clock time. No RTC -> millis() uptime.
 * Wiring:  I2C  SDA->GPIO21 SCL->GPIO22; rbAmp VCC->5 V; DS3231 VCC->3.3 V; GND
 *               common; ~4.7 kOhm pull-ups to 3.3 V.
 *          SD   (VSPI) SCK->GPIO18 MISO->GPIO19 MOSI->GPIO23 CS->GPIO5.
 *
 * Accuracy: +/-0.5% of reading per channel with the matched rbAmp CT.
 */
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <RbAmp.h>
#include <RTClib.h>
/* ---- configuration — the reader edits these ------------------------------ */
static const uint8_t      RBAMP_ADDR = 0x50;
static const uint8_t      METER_CH   = 0;
static const uint32_t     I2C_HZ     = 50000;    /* ESP32-Arduino: 50 kHz */
static const uint8_t      SD_CS      = 5;        /* microSD chip-select (VSPI default) */
static const char*        CSV_PATH   = "/energy.csv";
static const uint32_t     LOG_MS     = 30000;    /* one CSV row every 30 s */
/* Match to your clamp: Sct013_005 / _010 / _030 / _050 / _020. */
static const RbAmpCTModel MY_CT      = RbAmpCTModel::Sct013_030;
static const char CSV_HEADER[] = "timestamp,voltage_V,current_A,power_W,energy_Wh";
/* ---- state --------------------------------------------------------------- */
static RbAmp       dev(Wire, RBAMP_ADDR, RbAmpTopology::Single);
static RTC_DS3231  rtc;
static bool        rtc_ok = false;
static bool        sd_ok  = false;
static uint32_t    next_log_ms = 0;
static uint32_t    rows = 0, skipped = 0, sd_fail = 0;
/* Apply the CT model only if it differs (setCTModel() persists to flash). */
static void ensureCtModel() {
    uint8_t applied = 0;
    if (dev.readCTModelCh(METER_CH, applied) && applied == static_cast<uint8_t>(MY_CT)) {
        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()));
    }
}
/* Wall-clock from the RTC, or uptime if no RTC. Primitive args only. */
static void makeTimestamp(char* buf, size_t n) {
    if (rtc_ok) {
        DateTime now = rtc.now();
        snprintf(buf, n, "%04u-%02u-%02u %02u:%02u:%02u",
                 now.year(), now.month(), now.day(),
                 now.hour(), now.minute(), now.second());
    } else {
        snprintf(buf, n, "up+%lus", static_cast<unsigned long>(millis() / 1000UL));
    }
}
/* Create the file with a header row the first time it doesn't exist. */
static void ensureHeader() {
    if (!sd_ok || SD.exists(CSV_PATH)) return;
    File f = SD.open(CSV_PATH, FILE_WRITE);
    if (f) {
        f.println(CSV_HEADER);
        f.close();
        Serial.print(F("created "));
        Serial.println(CSV_PATH);
    } else {
        Serial.println(F("could not create CSV header on SD"));
    }
}
/* Append one row: persist to SD (open/append/flush/close so a row is never
 * lost on power-off) and mirror to Serial. Primitive args only. */
static void logRow(const char* ts, float v, float a, float p, double wh) {
    char line[96];
    snprintf(line, sizeof(line), "%s,%.1f,%.3f,%.1f,%.3f",
             ts, v, a, p, wh);
    Serial.println(line);
    if (!sd_ok) return;
    File f = SD.open(CSV_PATH, FILE_APPEND);
    if (!f) {
        sd_fail++;
        if (sd_fail % 10 == 1)
            Serial.println(F("SD append failed - still logging to Serial"));
        return;
    }
    f.println(line);
    f.flush();
    f.close();
    rows++;
}
/* ---- lifecycle ----------------------------------------------------------- */
void setup() {
    Serial.begin(115200);
    while (!Serial && millis() < 2000) {}
    Wire.begin();
    Wire.setClock(I2C_HZ);
    /* rbAmp */
    dev.setLogStream(&Serial);
    uint8_t tries = 0;
    while (!dev.begin()) {
        Serial.print(F("rbAmp begin failed: "));
        Serial.println(RbAmp::errorString(dev.lastError()));
        if (++tries % 5 == 0)
            Serial.println(F("check wiring: SDA/SCL/5V/GND + external pull-ups"));
        delay(1000);
    }
    Serial.println(F("rbAmp online"));
    ensureCtModel();
    /* RTC (optional) */
    rtc_ok = rtc.begin();
    if (rtc_ok) {
        if (rtc.lostPower()) {
            /* First power-up / dead backup cell: seed from build time. Replace
             * with a known-good time or a set-clock sketch for real accuracy. */
            rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
            Serial.println(F("RTC lost power - seeded from build time (set it properly)"));
        }
        Serial.println(F("timestamp source: DS3231 RTC"));
    } else {
        Serial.println(F("no DS3231 RTC - timestamps fall back to millis() uptime"));
    }
    /* SD (optional) */
    sd_ok = SD.begin(SD_CS);
    if (sd_ok) {
        ensureHeader();
    } else {
        Serial.println(F("no SD card - logging to Serial only"));
    }
    Serial.println(CSV_HEADER);   /* so the Serial stream is a self-describing CSV too */
    next_log_ms = millis();
}
void loop() {
    uint32_t now = millis();
    if (static_cast<int32_t>(now - next_log_ms) < 0) {
        delay(5);
        return;
    }
    next_log_ms = now + LOG_MS;
    /* Latch a period so energy().wh() advances for this row. A stale period is
     * noted but not fatal — the row still carries the live V/I/P + the current
     * Wh total (energy simply does not advance that interval). */
    RbAmpPeriodSnapshot snap;
    if (!dev.readPeriodSnapshot(snap)) {
        Serial.print(F("period stale/failed: "));
        Serial.println(RbAmp::errorString(dev.lastError()));
    }
    float v = dev.readVoltage();
    float a = dev.readCurrent(METER_CH);
    float p = dev.readPower(METER_CH);
    /* Skip the row on a transient bad read rather than write garbage. */
    if (isnan(v) || isnan(a) || isnan(p)) {
        skipped++;
        Serial.println(F("skip row - transient bad read"));
        return;
    }
    char ts[24];
    makeTimestamp(ts, sizeof(ts));
    logRow(ts, v, a, p, dev.energy().wh(METER_CH));
}

(Compile-verified on ESP32 with Arduino-CLI — RbAmp + RTClib + the bundled SD/SPI/Wire. Each row is opened, written, flushed and closed immediately, so a row is never lost to an unflushed buffer on power-off. The DS3231 RTC and rbAmp share the I²C bus; the SD card is on separate SPI pins.)

What the sketch is doing

Three small helpers do the logging — makeTimestamp(), ensureHeader(), and logRow() — and loop() ties them to the meter reads.

makeTimestamp() — real time, or uptime

cpp
if (rtc_ok) snprintf(buf, n, "%04u-%02u-%02u %02u:%02u:%02u", now.year(), ...);
else        snprintf(buf, n, "up+%lus", millis()/1000UL);

If a DS3231 was found at boot (rtc.begin()), rows get a real wall-clock timestamp; if not, they fall back to up+<seconds> uptime and the sketch says so on Serial. Either way the CSV is valid — you just lose absolute dates without the RTC.

ensureHeader() and logRow() — the CSV write path

cpp
File f = SD.open(CSV_PATH, FILE_APPEND);
f.println(line);
f.flush();
f.close();          // open -> append -> flush -> close, every single row

ensureHeader() writes the column header once, only when the file is first created. logRow() formats one line and does the important bit: opens the file in append mode, writes the row, then flushes and closes immediately. That per-row open/flush/close is deliberate — the row is on the card the instant it's written, so pulling the power (or the card) never loses buffered data. The same line goes to Serial.println() first, so the serial stream is a self-describing CSV you can redirect straight to a file on a PC.

loop() — read, guard, log

cpp
dev.readPeriodSnapshot(snap);              // latch a window so energy().wh() advances
float v = dev.readVoltage(); float a = dev.readCurrent(0); float p = dev.readPower(0);
if (isnan(v) || isnan(a) || isnan(p)) { skipped++; return; }   // skip a bad read, don't log garbage
makeTimestamp(ts, sizeof(ts));
logRow(ts, v, a, p, dev.energy().wh(0));

Every LOG_MS (30 s) it latches a metering period so the watt-hour total advances, reads the live V/I/P, and — if none came back NaN — stamps and writes the row. A transient bad read is skipped rather than written as garbage; a stale period is noted but the row is still written (with live V/I/P and the current Wh), because dropping a whole sample over one un-advanced energy step would be worse.

Using the data

Pop the card into a PC and open the CSV in a spreadsheet — plot power over the day, sum energy per hour, spot the fridge cycling or the water heater's duty cycle. Because the energy column is real host-integrated watt-hours, the totals reconcile with what you'd expect on the bill. Want it graphed live instead of after the fact? Put it in Home Assistant — but for a fully offline record, this file is yours.

Make it your own

  • More columns — the module also gives power factor and frequency; add them to CSV_HEADER and the logRow format string.
  • Daily files — start a new file per day (/2026-07-30.csv) from the RTC date, so a log doesn't grow without bound.
  • Set the clock properly — the RTC seeds from build time on first power-up; add a one-line rtc.adjust() with a known time, or sync from NTP if you put the ESP32 on WiFi.
  • Several circuits — log one row per module per interval by combining with the multi-module build.
  • Ship it somewhere — on an ESP32 you can also POST each row to a server or publish over MQTT, keeping the SD card as the offline backup.

The Arduino series, recap

You now have the full standalone toolkit — no network required: - Read it — one module on an OLED. - Scale it — several circuits on one bus, coherently. - Act on it — trip a relay on over-current. - Log it — this build, to SD/CSV.

[newsletter / subscribe block — deploy inserts the standard snippet]


Documentation & source code
📖 rbAmp Arduino library reference · 💻 rb-amp/rbamp-arduino on GitHub — issues, examples, ⭐
Local Current-Limit with Arduino — Trip a Relay Before the Breaker, No Cloud