Zum Inhalt springen

Local Current-Limit with Arduino — Trip a Relay Before the Breaker, No Cloud

30. Juli 2026 durch
Local Current-Limit with Arduino — Trip a Relay Before the Breaker, No Cloud
Administrator

The monitors show you the current. This one acts on it: when a circuit stays over your limit, the Arduino trips a relay to shed the load (or sounds an alert) — and it does it in its own loop, no Home Assistant, no cloud, no network round-trip. That local path is the point: the reaction is sub-second once the over-limit is confirmed, faster than anything that has to bounce through a hub.

⚠️ Read this first — what this is and isn't. This is a comfort-and-convenience layer, not a protection device. Your circuit breaker remains the safety device — never size a circuit or a load assuming this automation will act. And the relay you switch must be rated for the load: a small relay or smart-switch cannot break 30–50 A directly. For a high-current circuit, use the low-current relay to drive a properly rated contactor, and let an electrician size it. Mains work is dangerous; if you're not comfortable in a live panel, get a qualified person.

What you'll build

  • A rbAmp module metering one circuit's current.
  • A relay that trips when the current stays over LIMIT_ON amps, and releases below LIMIT_OFF (hysteresis, so it doesn't chatter at the setpoint).
  • Inrush debounce — a short sustained-overload delay so a motor or kettle's startup surge doesn't nuisance-trip.
  • Optional buzzer / LED alert. All local, in the Arduino loop.

Bill of materials

Item Qty Notes
rbAmp current module (I-module) 1 current-only I1 is the lean pick — a UI1 works too (its voltage channel just goes unused) · product
SCT-013 CT 1 rating to match the circuit · product
ESP32 dev board (Arduino framework) 1 the compile-verified target (portable to other Arduino boards)
Relay module 1 isolated, rated for the load — or driving a contactor for high current
Buzzer / LED (optional) 1 local alert

Wire it up

The rbAmp module sits on the I²C bus (SDA→GPIO21, SCL→GPIO22, rbAmp VCC 5 V, common ground — why 5 V power but a 3.3 V bus?). The relay module's control input goes to GPIO26; set RELAY_ACTIVE_HIGH in the sketch to match your board (many opto-isolated boards are active-LOW). Wire the load through the relay's NO contact, so a de-energised relay means the load is OFF. An optional LED/buzzer on GPIO25 lights while tripped. The CT clamps around the circuit conductor — non-invasive on the current side.

The logic: threshold, hysteresis, debounce

Three ideas keep it from misbehaving:

  • Hysteresis — trip at LIMIT_ON (say 18 A) but only release below LIMIT_OFF (say 15 A). A single setpoint would chatter every time the load hovers near it; the gap gives it somewhere to settle.
  • Inrush debounce — require the current to stay over the limit for a few seconds before tripping. Motors, pumps and kettles pull a big startup surge that's over the limit for a fraction of a second — you don't want to trip on that.
  • The rbAmp component reads the module's current-RMS register each loop (the module computes RMS internally at about 5 Hz, so each read is the latest windowed value) — the Arduino compares it to the limits and drives the relay.

The sketch

Set LIMIT_ON / LIMIT_OFF to your amps and MY_CT to your clamp, match RELAY_ACTIVE_HIGH to your relay board, and flash. ESP32 (Arduino framework) at 50 kHz; relay on GPIO26, alert on GPIO25:

cpp
/**
 * LoadLimitRelay.ino — local current-limit -> relay / alert (standalone, no network).
 *
 * One rbAmp current module meters a circuit; when the current stays over a limit
 * for a sustained interval, the Arduino trips a relay to shed the load and lights
 * an alert. Everything runs in the loop, so the reaction is sub-second once the
 * overload is confirmed — no network, no HA round-trip.
 *
 * =========================== SAFETY — READ FIRST ===========================
 *  * The relay/switch MUST be rated for the load. A small hobby relay or
 *    smart-plug CANNOT break 30-50 A directly. Switch a high-current circuit
 *    with a properly rated CONTACTOR that this low-current relay drives.
 *  * This is a COMFORT / CONVENIENCE layer, NOT a protection device. The circuit
 *    BREAKER remains the safety device. Never size a circuit or a load assuming
 *    this automation will act. For a hard cut-off use a hardware overcurrent
 *    device (breaker / fuse / thermal cut-out).
 *  * The relay reaction is deliberately delayed by OVERLOAD_MS to ride through
 *    inrush — it is NOT instantaneous overcurrent protection.
 * ===========================================================================
 *
 * Module:  a current-only rbAmp I-module (I1) at the default address 0x50. Only
 *          current is needed, so no voltage-sensing variant is required;
 *          readCurrent(0) is the metered channel. (A UI1 works too — its voltage
 *          channel simply goes unused.)
 *
 * Board:   ESP32 (Arduino framework); I2C @ 50 kHz. Non-ESP32 host = 100 kHz.
 * Wiring:  SDA->GPIO21, SCL->GPIO22; rbAmp VCC->5 V; common GND; ~4.7 kOhm
 *          pull-ups to 3.3 V; RELAY_PIN(GPIO26)->opto-isolated relay IN;
 *          ALERT_PIN(GPIO25)->LED(+resistor)/buzzer. Wire the load through the
 *          relay NO contact so de-energised = load OFF.
 *
 * Accuracy: +/-0.5% of reading per channel with the matched rbAmp CT.
 */
#include <Wire.h>
#include <RbAmp.h>
/* ---- configuration — the reader edits these ------------------------------ */
static const uint8_t      RBAMP_ADDR = 0x50;
static const uint8_t      METER_CH   = 0;       /* current channel on the module */
static const uint32_t     I2C_HZ     = 50000;   /* ESP32-Arduino: 50 kHz */
/* Match to your clamp: Sct013_005 / _010 / _030 / _050 / _020. */
static const RbAmpCTModel MY_CT      = RbAmpCTModel::Sct013_030;
/* Threshold with hysteresis (amps). Trip when the current stays at/above
 * LIMIT_ON; a pending overload clears only when it falls below LIMIT_OFF, so
 * the relay does not chatter around the setpoint. Keep LIMIT_OFF < LIMIT_ON. */
static const float        LIMIT_ON   = 10.0f;
static const float        LIMIT_OFF  = 8.0f;
/* Debounce / timing. */
static const uint32_t     SAMPLE_MS   = 200;    /* current sample cadence */
static const uint32_t     OVERLOAD_MS = 3000;   /* sustained over-limit before trip (rides inrush) */
static const uint32_t     COOLDOWN_MS = 10000;  /* stay shed before an auto re-arm attempt */
/* ---- relay / alert GPIO -------------------------------------------------- */
static const uint8_t      RELAY_PIN         = 26;
static const uint8_t      ALERT_PIN         = 25;
#define RELAY_ACTIVE_HIGH  1     /* 1: HIGH energises. Set 0 for active-LOW opto modules. */
static inline void relayLoadOn(bool on) {
    bool level = (RELAY_ACTIVE_HIGH ? on : !on);
    digitalWrite(RELAY_PIN, level ? HIGH : LOW);
}
static inline void alertOn(bool on) {
    digitalWrite(ALERT_PIN, on ? HIGH : LOW);
}
/* ---- state machine ------------------------------------------------------- */
enum LoadState { ARMED, OVER_PENDING, TRIPPED };
static LoadState state = ARMED;
static RbAmp    dev(Wire, RBAMP_ADDR, RbAmpTopology::Single);
static uint32_t next_sample_ms = 0;
static uint32_t over_since_ms  = 0;     /* when the current first crossed LIMIT_ON */
static uint32_t tripped_at_ms  = 0;
static uint32_t trip_count     = 0;
static uint32_t bad_reads      = 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()));
    }
}
/* Params are uint8_t (not LoadState) so the Arduino .ino auto-prototype
 * generator — which inserts prototypes above the enum definition — compiles. */
static const char* stateName(uint8_t s) {
    switch (s) {
        case ARMED:        return "ARMED";
        case OVER_PENDING: return "OVER_PENDING";
        case TRIPPED:      return "TRIPPED";
    }
    return "?";
}
static void enter(uint8_t s, float a) {
    Serial.print(F("["));
    Serial.print(millis());
    Serial.print(F("ms] "));
    Serial.print(stateName(state));
    Serial.print(F(" -> "));
    Serial.print(stateName(s));
    Serial.print(F("  (I="));
    Serial.print(a, 3);
    Serial.println(F(" A)"));
    state = static_cast<LoadState>(s);
}
/* ---- lifecycle ----------------------------------------------------------- */
void setup() {
    Serial.begin(115200);
    while (!Serial && millis() < 2000) {}
    /* Drive the outputs to the SAFE armed state FIRST (load powered, no alert),
     * before any bus traffic, so a slow begin() never leaves the pin floating. */
    pinMode(RELAY_PIN, OUTPUT);
    pinMode(ALERT_PIN, OUTPUT);
    relayLoadOn(true);
    alertOn(false);
    Wire.begin();
    Wire.setClock(I2C_HZ);
    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();
    Serial.print(F("armed: trip>="));
    Serial.print(LIMIT_ON, 1);
    Serial.print(F("A for "));
    Serial.print(OVERLOAD_MS / 1000.0f, 1);
    Serial.print(F("s, clear<"));
    Serial.print(LIMIT_OFF, 1);
    Serial.println(F("A"));
    next_sample_ms = millis();
}
void loop() {
    uint32_t now = millis();
    if (static_cast<int32_t>(now - next_sample_ms) < 0) {
        delay(2);
        return;
    }
    next_sample_ms = now + SAMPLE_MS;
    float a = dev.readCurrent(METER_CH);
    /* Graceful: a transient bad/NaN read must not cause a false trip or a false
     * release. Hold the current state and skip this sample. */
    if (isnan(a)) {
        bad_reads++;
        if (bad_reads % 10 == 1) {
            Serial.print(F("read failed ("));
            Serial.print(RbAmp::errorString(dev.lastError()));
            Serial.println(F(") - holding state"));
        }
        return;
    }
    switch (state) {
        case ARMED:
            /* Load powered; watch for the current crossing the trip limit. */
            if (a >= LIMIT_ON) {
                over_since_ms = now;
                enter(OVER_PENDING, a);
            }
            break;
        case OVER_PENDING:
            /* Load still powered. Clear on a drop below the release threshold
             * (inrush/transient passed); trip on sustained overload. */
            if (a < LIMIT_OFF) {
                enter(ARMED, a);
            } else if (static_cast<int32_t>(now - over_since_ms) >= static_cast<int32_t>(OVERLOAD_MS)) {
                relayLoadOn(false);
                alertOn(true);
                tripped_at_ms = now;
                trip_count++;
                enter(TRIPPED, a);
                Serial.print(F("  LOAD SHED (trip #"));
                Serial.print(trip_count);
                Serial.println(F(")"));
            }
            break;
        case TRIPPED:
            /* Load shed (current is ~0 now). Hold for the cooldown, then re-arm
             * and re-close; if the overload persists it will trip again. */
            if (static_cast<int32_t>(now - tripped_at_ms) >= static_cast<int32_t>(COOLDOWN_MS)) {
                relayLoadOn(true);
                alertOn(false);
                enter(ARMED, a);
                Serial.println(F("  cooldown elapsed - load re-energised"));
            }
            break;
    }
}

(Compile-verified on ESP32 with Arduino-CLI — Wire + RbAmp only. The relay pin is driven to the safe load-on state in setup() before any bus traffic, so a slow begin() never leaves it floating.)

What the sketch is doing

The heart of it is a three-state machine — ARMED → OVER_PENDING → TRIPPED — evaluated once per sample in loop(). A couple of tiny helpers keep it clean.

The safe-by-default output

cpp
static inline void relayLoadOn(bool on) {
    bool level = (RELAY_ACTIVE_HIGH ? on : !on);   // one #define handles both board polarities
    digitalWrite(RELAY_PIN, level ? HIGH : LOW);
}

relayLoadOn() hides the active-high/active-low difference behind one #define, so the rest of the code just says "load on" or "load off." And setup() calls relayLoadOn(true) before any I²C traffic, so a slow begin() can never leave the relay pin floating.

The state machine — loop()

cpp
float a = dev.readCurrent(METER_CH);
if (isnan(a)) { /* hold state, skip this sample */ return; }
switch (state) {
  case ARMED:        if (a >= LIMIT_ON) { over_since_ms = now; enter(OVER_PENDING, a); }              break;
  case OVER_PENDING: if (a <  LIMIT_OFF) enter(ARMED, a);
                     else if (now - over_since_ms >= OVERLOAD_MS) { relayLoadOn(false); enter(TRIPPED, a); } break;
  case TRIPPED:      if (now - tripped_at_ms >= COOLDOWN_MS) { relayLoadOn(true); enter(ARMED, a); }  break;
}

Top to bottom:

  • ARMED — load powered, watching. The moment current hits LIMIT_ON it timestamps the crossing and moves to OVER_PENDING. It hasn't tripped yet.
  • OVER_PENDING — still powered, now on a stopwatch. If current falls back below LIMIT_OFF (an inrush surge passing, or the load easing) it returns to ARMED — no trip. If it stays over the limit for OVERLOAD_MS, it sheds: relay off, alert on, → TRIPPED.
  • TRIPPED — load shed. After COOLDOWN_MS it re-arms and re-closes; if the overload is still there it simply trips again.

That's the whole safety-relevant behaviour. The hysteresis is the two different thresholds (LIMIT_ON to trip, lower LIMIT_OFF to clear); the inrush debounce is the OVERLOAD_MS wait, which a motor or kettle's startup surge rides straight through. A transient NaN read holds the current state and skips the sample, so one bad read can never false-trip or false-release. enter() logs every transition to Serial with a timestamp and the current.

How fast is it?

Local and quick. The module refreshes its current RMS about every 200 ms (~5 Hz); the Arduino reads it each loop and, once the over-limit has held past the debounce window, flips the relay in the next loop pass — well under a second of decision latency, with no HA or cloud in the path. That's the advantage of doing control on the same microcontroller that reads the meter.

Make it your own

  • Tune itLIMIT_ON / LIMIT_OFF / OVERLOAD_MS / COOLDOWN_MS are all constants at the top; set them for your circuit.
  • Manual reset — instead of auto re-arming after the cooldown, wait for a push-button so a human clears the trip.
  • Priorities — meter several circuits (the multi-module build) and shed the lowest-priority load first to stay under a whole-house limit.
  • Log the trips — write each transition to an SD card so you can see when and how often it fired (the logger build).
  • Proportional control — instead of a hard on/off relay, drive a TRIAC dimmer and hold a target current with a PID loop (a bigger project — a teaser for later).

Whatever you add, keep the framing above: this stays a convenience layer, not a protection device.

Where to go next

  • Log what happened — write the trip events and periodic current to an SD card or CSV over serial (next in this series).
  • Want it in Home Assistant instead? The HA version — sliders, alerts, priority load-shedding across a whole panel — is real-time load control with rbAmp.

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


Documentation & source code
📖 rbAmp Arduino library reference · 💻 rb-amp/rbamp-arduino on GitHub — issues, examples, ⭐
Meter Several Circuits with One Arduino — a Multi-Module rbAmp Bus