Passa al contenuto

Meter Several Circuits with One Arduino — a Multi-Module rbAmp Bus

30 luglio 2026 di
Meter Several Circuits with One Arduino — a Multi-Module rbAmp Bus
Administrator

The standalone monitor put one module on a screen. Real installations have more than one circuit — mains plus a few branches, or three phases. The naive answer (one meter per circuit, each on its own serial port) runs out of UARTs fast. The rbAmp answer is a bus: two or three modules share the same two I²C wires behind one Arduino, and a single broadcast tells them all to snapshot at the same instant — so the per-circuit numbers actually add up to an honest total.

What you'll build

  • Per-circuit voltage / current / real power from each module.
  • A coherent total active power — the sum of the modules, all latched on the same instant (not three independently-sampled readings that don't reconcile — see why that matters).
  • Everything on one I²C bus, one Arduino — add circuits by adding modules, no new microcontroller.

Bill of materials

Item Qty Notes
rbAmp Basic Wattmeter (UI1) 3 one per circuit · product
SCT-013 CT 3 one per module, rating per circuit · product
ESP32 dev board (Arduino framework) 1 the compile-verified target
Jumper wires the shared I²C bus

Wire the shared bus

All modules sit on the same SDA / SCL / power / ground — they differ only by address:

plaintext
Arduino          module A (0x50)   module B (0x51)   module C (0x52)
-------          ---------------   ---------------   ---------------
SDA ────────┬────── SDA ────┬────── SDA ────┬────── SDA
SCL ────────┼────── SCL ────┼────── SCL ────┼────── SCL
5V  ────────┼────── VCC     ├────── VCC     ├────── VCC
GND ────────┴────── GND ────┴────── GND ────┴────── GND
                    │                │                │
                 CT on circuit A   CT on circuit B   CT on circuit C
  • Pull-ups: each module ships with on-board pull-ups. On a multi-module bus you want one pull-up pair, so keep them on the first module and disable them on the rest (the module hardware doc shows the jumper). Too many pull-ups in parallel drag the bus resistance too low.
  • The CTs clamp around their conductors — the current side is non-invasive. Power each module at its rated voltage. Mains work is dangerous; if you're not comfortable in a live panel, get a qualified person.

Give each module an address

Modules ship at 0x50, so before they can share a bus you assign the second and third a unique address (0x51, 0x52). On current firmware this is a plain production operation — no special mode or tooling — you stage the new address, arm it, and commit; the module reboots at its new address. Do it one module at a time, with the others disconnected, so there's no address clash — the RbAmpFleet::provision() helper (or a single module's prepareAddressChange() / commitAddressChange()) runs that two-phase commit. No jumper, no button, no special mode — just one module on the bus while you assign it.

Synchronize, then read

Here's the part that makes the total trustworthy. Instead of reading three modules one after another (each caught at a slightly different moment), you send one broadcast "latch now" to the whole bus — every module freezes its measurement window on the same instant. Then you read each module's frozen snapshot in turn. The reads are sequential, but the values all belong to the same moment, so summing them is honest. It's the same coherence principle as the three-phase build, here in Arduino C++.

The sketch

Three UI1 modules at 0x50 / 0x51 / 0x52, one ESP32 (Arduino framework) at 50 kHz. The RbAmpFleet helper scans the bus, opts every module into the broadcast latch once, then each energy window fires one General-Call latch and reads each module's frozen snapshot:

cpp
/**
 * MultiModuleBus.ino — three rbAmp modules on one I2C bus: per-module power +
 * a coherent fleet total, synchronised by the v1.3 General-Call latch.
 *
 * Three UI1 modules, each metering one independent mains circuit, share a single
 * two-wire bus behind one ESP32 at 0x50 / 0x51 / 0x52. Every energy window the
 * host fires ONE General-Call broadcast latch, so all three latch their period on
 * the same instant (coherent energy). Live V/I/P is read sequentially; the
 * broadcast is only for period/energy coherence. Requires v1.3+ firmware.
 *
 * Board:  ESP32 (Arduino framework)  -- esp32:esp32:esp32
 * Bus:    I2C @ 50 kHz (Wire.setClock(50000)) on ESP32-Arduino; non-ESP32 = 100 kHz.
 *
 * Wiring: SDA->GPIO21, SCL->GPIO22 (all modules); rbAmp VCC->5 V; common GND;
 *         ~4.7 kOhm pull-ups on SDA/SCL to 3.3 V (host logic level).
 *
 * ADDRESSING (do this ONCE, before running this sketch): a factory module answers
 * at 0x50. Bring modules up ONE AT A TIME and give each a unique address with the
 * production-OK two-phase commit (RbAmpFleet::provision(), or a single module's
 * prepareAddressChange()/commitAddressChange() — see example 05_AddressChange).
 * Assign 0x51 and 0x52; leave the first at 0x50. No jumper, no button, no special
 * mode — just one module on the bus at a time while you assign it.
 *
 * Accuracy: +/-0.5% of reading per channel with the matched rbAmp CT.
 */
#include <Wire.h>
#include <RbAmpFleet.h>
/* ---- configuration -------------------------------------------------------- */
static const uint8_t  SDA_PIN   = 21;
static const uint8_t  SCL_PIN   = 22;
static const uint32_t I2C_HZ    = 50000;      /* ESP32-Arduino: 50 kHz */
static const uint8_t  GC_GROUP  = 0x00;       /* 0x00 = all-call (latch every module) */
static const uint32_t LIVE_MS   = 1000;       /* per-module V/I/P refresh */
static const uint32_t WINDOW_MS = 30000;      /* coherent energy window (GC latch) */
/* ---- state ---------------------------------------------------------------- */
static RbAmpFleet fleet(Wire);
static size_t     g_count = 0;
static uint16_t   g_tick  = 0;
static uint32_t   next_live_ms   = 0;
static uint32_t   next_window_ms = 0;
/* ---- live per-module V/I/P + running total active power ------------------- */
static void showLive() {
    Serial.println(F("-- live --"));
    float p_total = 0.0f;
    for (size_t i = 0; i < g_count; ++i) {
        RbAmp* dev = fleet.get(i);
        if (!dev) continue;
        float v = dev->readVoltage();
        float a = dev->readCurrent(0);
        float p = dev->readPower(0);
        if (!isnan(p)) p_total += p;
        Serial.print(F("  0x"));
        Serial.print(dev->address(), HEX);
        Serial.print(F("  "));
        Serial.print(isnan(v) ? 0.0f : v, 1);   Serial.print(F(" V  "));
        Serial.print(isnan(a) ? 0.0f : a, 3);   Serial.print(F(" A  "));
        Serial.print(isnan(p) ? 0.0f : p, 1);   Serial.println(F(" W"));
    }
    Serial.print(F("  TOTAL active power = "));
    Serial.print(p_total, 1);
    Serial.println(F(" W"));
}
/* ---- coherent energy window: one GC latch -> read each -> aggregate ------- */
static void rollEnergyWindow() {
    g_tick++;
    /* One broadcast latches every GC-enabled module on the same instant. */
    fleet.gcLatch(GC_GROUP, g_tick, /*settle_ms=*/50);
    /* Verify each module accepted this tick (fleet sync / missed-frame skew). */
    RbAmpFleetSync sync[RBAMP_FLEET_MAX_MODULES];
    size_t missed = 0;
    fleet.checkSync(g_tick, sync, RBAMP_FLEET_MAX_MODULES, missed);
    Serial.print(F("== window tick "));
    Serial.print(g_tick);
    Serial.print(F(": "));
    Serial.print(g_count - missed);
    Serial.print(F("/"));
    Serial.print(g_count);
    Serial.println(F(" in sync =="));
    /* Read the already-latched period on each module (skip_latch=true), which
     * rolls that module's host-integrated Wh accumulator. */
    for (size_t i = 0; i < g_count; ++i) {
        RbAmp* dev = fleet.get(i);
        if (!dev) continue;
        RbAmpPeriodSnapshot snap;
        bool ok = dev->readPeriodSnapshot(snap, /*settle_ms=*/0, /*skip_latch=*/true);
        Serial.print(F("  0x"));
        Serial.print(dev->address(), HEX);
        if (!sync[i].in_sync) {
            Serial.print(F("  [missed tick, gc_tick="));
            Serial.print(sync[i].gc_tick);
            Serial.print(sync[i].reachable ? F("]") : F(", unreachable]"));
        }
        if (!ok) {
            Serial.print(F("  period skipped: "));
            Serial.println(RbAmp::errorString(dev->lastError()));
            continue;
        }
        Serial.print(F("  avg_P="));
        Serial.print(snap.avg_p[0], 1);
        Serial.print(F(" W  Wh="));
        Serial.println(dev->energy().wh(0), 3);
    }
    float  p_total  = 0.0f;
    double wh_total = 0.0;
    fleet.totalPower(p_total);
    fleet.totalEnergyWh(wh_total);
    Serial.print(F("  FLEET total P="));
    Serial.print(p_total, 1);
    Serial.print(F(" W   total Wh="));
    Serial.println(wh_total, 3);
}
/* ---- lifecycle ------------------------------------------------------------ */
void setup() {
    Serial.begin(115200);
    while (!Serial && millis() < 2000) {}
    Wire.begin(SDA_PIN, SCL_PIN);
    Wire.setClock(I2C_HZ);
    /* Discover every rbAmp on the bus (PRODUCT_ID confirmed). */
    size_t added = 0;
    if (!fleet.scan(/*match_product=*/true, added)) {
        Serial.println(F("scan failed - bus may be compromised (stuck SDA?)"));
        return;
    }
    g_count = fleet.count();
    Serial.print(F("fleet: "));
    Serial.print(g_count);
    Serial.println(F(" module(s)"));
    if (g_count == 0) {
        Serial.println(F("no modules - check wiring + that each has a unique address"));
        return;
    }
    /* Opt the whole fleet into GC latch (persisted + reset; one-time, ~1 s each). */
    size_t gc_ok = 0;
    fleet.enableGcAll(GC_GROUP, gc_ok);
    Serial.print(F("GC latch enabled on "));
    Serial.print(gc_ok);
    Serial.print(F("/"));
    Serial.print(g_count);
    Serial.println(F(" module(s)"));
    uint32_t now   = millis();
    next_live_ms   = now;
    next_window_ms = now + WINDOW_MS;
}
void loop() {
    if (g_count == 0) { delay(1000); return; }
    uint32_t now = millis();
    if (static_cast<int32_t>(now - next_live_ms) >= 0) {
        next_live_ms = now + LIVE_MS;
        showLive();
    }
    if (static_cast<int32_t>(now - next_window_ms) >= 0) {
        next_window_ms = now + WINDOW_MS;
        rollEnergyWindow();
    }
    delay(5);
}

(Compile-verified on ESP32 with Arduino-CLI — RbAmpFleet + Wire, no extra libraries. enableGcAll() is a one-time opt-in; gcLatch() broadcasts, checkSync() reports any module that missed the tick, and totalEnergyWh() gives the fleet's summed energy.)

What the sketch is doing

The RbAmpFleet helper does the bookkeeping — discovering the modules, opting them into the broadcast latch, and totalling them. Two functions run in the loop: showLive() (per-second display) and rollEnergyWindow() (per energy window).

Setup — scan the bus, enable the fleet latch

cpp
RbAmpFleet fleet(Wire);
fleet.scan(/*match_product=*/true, added);   // find every rbAmp on the bus
fleet.enableGcAll(GC_GROUP, gc_ok);           // opt them all into the broadcast latch (one-time)

scan() walks the bus and registers every rbAmp it finds (confirming each by product ID); fleet.count() tells you how many answered, so a missing or mis-addressed module is obvious at boot. enableGcAll() sets the General-Call latch flag on every module once — after that, a single broadcast can latch the whole group.

showLive() — per-module readings + a running total

cpp
for (size_t i = 0; i < g_count; ++i) {
    RbAmp* dev = fleet.get(i);
    float p = dev->readPower(0);
    if (!isnan(p)) p_total += p;   // ... print V / A / W for this module
}

Walks the fleet and reads each module's live voltage/current/power in turn. These live reads don't need to be synchronized — they're for the display — and it sums the per-module power into p_total for a live whole-panel figure.

rollEnergyWindow() — the coherent part

cpp
fleet.gcLatch(GC_GROUP, ++g_tick, /*settle=*/50);                 // ONE broadcast latches all modules together
fleet.checkSync(g_tick, sync, RBAMP_FLEET_MAX_MODULES, missed);   // did each accept the tick?
// per module: dev->readPeriodSnapshot(snap, 0, /*skip_latch=*/true);   // read the frozen window
fleet.totalPower(p_total);  fleet.totalEnergyWh(wh_total);

This is why the total is trustworthy. gcLatch() sends one General-Call frame to address 0x00 — every enabled module freezes its measurement window on that same instant (sub-10-µs skew across the fleet). checkSync() verifies each module actually accepted the tick and names any that missed (a handy fleet-health line). Then the reads: readPeriodSnapshot(..., skip_latch=true) reads each module's already-frozen window without re-latching, which rolls that module's watt-hour total. totalPower() / totalEnergyWh() sum the fleet. Because every module latched together, Σ Pi and the summed Wh are coherent — not three readings taken at three different moments.

The two-cadence loop()

Same idea as the single-module monitor: showLive() on a 1 s timer, rollEnergyWindow() on the energy-window timer — the live display stays responsive while the coherent latch runs at its own slower cadence.

Make it your own

  • More circuitsscan() handles however many modules are on the bus; give each a unique address (see above) and they join the fleet automatically. Mind the bus-loading / pull-up rules as the count grows.
  • Independent fleets — give unrelated groups different group_ids and latch each on its own cadence, all on one bus.
  • Add a screen — drop in the OLED from the monitor build, or just publish p_total and the fleet Wh.
  • Per-circuit actions — trip a relay per module over its own limit (combine with the relay build).
  • Log the panel — one CSV row per module per interval to an SD card (the logger build).

Where to go next

  • Act on it — trip a relay or alert when a circuit crosses a current limit, locally, no network.
  • Log it — periodic per-circuit energy to an SD card or CSV over serial.
  • Want it in Home Assistant instead? The same multi-module bus is the whole panel on one ESP32 with ESPHome.

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


Documentation & source code
📖 rbAmp Arduino library reference · 💻 rb-amp/rbamp-arduino on GitHub — issues, examples, ⭐
Build a Standalone Arduino Energy Monitor with rbAmp — Watts on an OLED, No Network