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_ONamps, and releases belowLIMIT_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 belowLIMIT_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:
/**
* 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
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()
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_ONit timestamps the crossing and moves toOVER_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 forOVERLOAD_MS, it sheds: relay off, alert on, → TRIPPED. - TRIPPED — load shed. After
COOLDOWN_MSit 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 it —
LIMIT_ON/LIMIT_OFF/OVERLOAD_MS/COOLDOWN_MSare 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]