Heads up: Zendure sent the SolarFlow 2400 AC+ and the AB3000L battery for this project, and PCBWay sponsored the video. Some of the links in this post are affiliate links — if you buy through them I earn a small commission at no extra cost to you. I only link to gear I actually used. The full details are on my Affiliate Disclosure page.
A while back the power tripped while my screw compressor was running. Normally that’s a non-event — flip the breaker back on, carry on with your day. But a screw compressor doesn’t coast to a graceful stop. Power dies mid-cycle, the intake valve doesn’t close in time, and back-pressure shoves oil straight out of the rotor housing and into the air filter.
When I pulled that filter afterwards, it was soaked — oil sitting somewhere oil is very much not supposed to be. There’s even a sticker right next to the e-stop telling you not to kill the power while the machine is running. Lesson received, the hard way. This is the story of how I stopped hoping and actually fixed it.
The math: why a “3 kW” motor pulls 27 amps
Here’s the awkward part. The type plate says 3 kW. The listing I bought it from said 3.5. Both numbers are technically fine. Neither one is the number that actually matters.
As the tank fills, the motor has to fight harder, and the current it pulls climbs right along with the pressure. By the top of the cycle the phase it’s sitting on is well into the mid-20s of amps — I’ve watched it spike past 27. That’s the whole-phase current, not just the compressor on its own, but the breaker doesn’t care which machine pulls the amps. Cross 16 A on that phase and it trips. Full stop.
And I’ll be straight with you: I keep buying machines my workshop can’t actually run. I know exactly where the ceiling is. I bought this one anyway.
Three ways to fix a power limit (two of them are fantasies)
Option one: upgrade the grid connection. For a one-man workshop that’s a five-figure bill and a waiting list measured in years. That’s not a fix, that’s a fantasy.
Option two: rewire the shop for more current. Real work, real money — and it still doesn’t change what the utility hands me at the meter.
Option three: babysit it. The one I’d actually been doing — don’t let the tank fill past about 10 bar, and keep one eye on the gauge. Which works perfectly, right up until the day you forget, or the power trips on its own. Which is exactly how I got oil in the filter.
The reframe: I don’t have a power problem, I have a peak problem
Here’s the idea that unlocked it. My average draw is low. It’s only that short, ugly burst at the very top of the fill that crosses my line. The rest of the time there’s plenty of headroom.
And a battery is very, very good at covering a short, ugly burst.
Zendure sent over their SolarFlow 2400 AC+ and an AB3000L battery, and I didn’t want to test it as a camping power bank or a box that sits in a cupboard waiting for a blackout. I wanted to know if it could fix this. The SolarFlow is AC-coupled, so it sits on the AC side of the shop — I’m not redesigning the electrical system and I’m not touching the meter. It plugs in and becomes part of the workshop’s power. That’s the whole appeal.
How peak shaving actually works
The idea is simple. The compressor still pulls everything at once — I’m not making it more efficient and I’m not making it ask for less. The battery just supplies the part of the spike that goes over my limit, so the grid side never sees it.
The battery doesn’t fix the compressor. It changes who pays for the peak.
The catch is that a battery on its own is dumb. It doesn’t know the compressor is about to scream, it doesn’t know what my limit is, and if it just dumps power constantly it’s empty in an hour. It needs a brain.
Giving the battery a brain (Homey, local MQTT, and Claude)
This is the part I’m actually proud of. My whole workshop already runs through Homey, with an energy dongle reporting the live current on each phase.
One detail that matters more than it sounds: I run the Zendure in local mode. Instead of leaning on Zendure’s cloud, the battery publishes its state over MQTT. I run an MQTT broker on the Homey itself and connect to it with the community Zendure Local app. Everything stays on my LAN — no round-trip to a server in another country every time I want to nudge the output. For peak shaving that low latency is the whole game: it’s the difference between catching a spike and watching the breaker trip while you wait on the cloud.
The rule I wanted was: watch phase 3, and the second it climbs toward my limit, tell the Zendure to feed in exactly enough power to hold the grid side flat. I’m not a software guy, so I sat down with Claude for an evening and we wrote it together — a small Homey script that watches the phase, does the math, and drives the battery.
The code, walked through
Here’s the whole script. It runs on a Homey flow that fires whenever the Energy Dongle reports a power change. I’ve redacted my own device IDs — swap in yours from the Homey developer tools.
// === Peak-shaving + opportunistic charging for the Zendure SF2400AC+ ===
// Keeps grid phase 3 under a safe current limit; uses spare headroom to
// refill the battery. DISCHARGE is instant (no smoothing/throttle) with a
// flat DISCHARGE_OFFSET_W feed-forward boost; CHARGE stays gentle.
//
// LOGGING: every run writes one CSV row into the 'Simple (Sys) Log' app via
// its flow card - filter by time and Export as CSV/Excel from inside that app.
// Message columns: grid_A,load_A,batt_W,target_W,soc,mode
// mode: 1 = export, 0 = rest/stop, -1 = charge
// Set LOG_ENABLED = false to stop logging when you're not filming.
//
// Run it from a Flow: WHEN Energy Dongle power changed -> Run script battery
// ----- CONFIG (tweak these) -----
const DONGLE_ID = 'YOUR-ENERGY-DONGLE-ID'; // Homey Energy Dongle
const ZENDURE_ID = 'YOUR-ZENDURE-DEVICE-ID'; // Sf2400AC+
const LIMIT_A = 13; // safe current limit, well under the 16A fuse
const CHARGE_BELOW_A = 10; // only charge when avg phase-3 load is below this
const MAX_POWER = 2400; // Zendure max charge/export power in watts
const MIN_SOC = 15; // don't discharge the battery below this %
const MAX_SOC = 100; // don't charge the battery above this %
const DISCHARGE_OFFSET_W = 200; // extra watts added on top of every export command
const FAST_WINDOW = 0; // discharge smoothing, seconds (0 = instant raw reading)
const AVG_WINDOW = 30; // long average for the gentle CHARGE decision (seconds)
const MIN_UPDATE = 10; // min seconds between updates - CHARGE/REST path only
const DEADBAND_W = 25; // skip re-sending changes smaller than this (watts)
const LOG_ENABLED = true; // write a CSV row to Simple (Sys) Log every run
// --------------------------------
const now = Date.now();
const dongle = await Homey.devices.getDevice({ id: DONGLE_ID });
const zendure = await Homey.devices.getDevice({ id: ZENDURE_ID });
// Net current the grid delivers on phase 3 (Energy Dongle reports whole amps)
const gridA3 = dongle.capabilitiesObj['measure_current.phase3'].value || 0;
// Actual phase-3 voltage, used to convert between amps and watts
const voltage3 = dongle.capabilitiesObj['measure_voltage.phase3'].value || 230;
// Zendure power: NEGATIVE when discharging/exporting, POSITIVE when charging
const battW = zendure.capabilitiesObj['measure_power'].value || 0;
// Battery state of charge (%)
const soc = zendure.capabilitiesObj['measure_battery'].value;
// True household load on phase 3 = grid current with the battery's effect removed.
// battW is negative on discharge / positive on charge, so SUBTRACTING battW/V
// correctly reconstructs the real load in both directions.
const loadA3 = gridA3 - (battW / voltage3);
// --- rolling buffer used for the gentle CHARGE average ---
let samples = await global.get('p3_samples');
if (!Array.isArray(samples)) samples = [];
samples.push([now, loadA3]);
samples = samples.filter(s => now - s[0] <= AVG_WINDOW * 1000);
await global.set('p3_samples', samples);
// instLoad drives DISCHARGE: raw latest reading, or a tiny FAST_WINDOW average
let instLoad = loadA3;
if (FAST_WINDOW > 0) {
const fast = samples.filter(s => now - s[0] <= FAST_WINDOW * 1000);
instLoad = fast.reduce((sum, s) => sum + s[1], 0) / fast.length;
}
// slowAvg drives CHARGE: long, gentle average
const slowAvg = samples.reduce((sum, s) => sum + s[1], 0) / samples.length;
const lastUpdate = (await global.get('p3_lastUpdate')) || 0;
const lastSent = await global.get('p3_lastSent');
// ---- DECIDE: what should the battery be doing right now ----
let targetW;
let mode;
let modeNum;
if (instLoad > LIMIT_A) {
mode = 'EXPORT'; modeNum = 1;
targetW = Math.round((instLoad - LIMIT_A) * voltage3) + DISCHARGE_OFFSET_W;
if (targetW > MAX_POWER) targetW = MAX_POWER;
if (soc != null && soc <= MIN_SOC) { targetW = 0; mode = 'EXPORT-blocked'; }
} else if (lastSent != null && lastSent > 0) {
mode = 'STOP'; modeNum = 0; // was exporting, load cleared -> stop now
targetW = 0;
} else if (slowAvg < CHARGE_BELOW_A) {
mode = 'CHARGE'; modeNum = -1;
targetW = -Math.min(Math.round((CHARGE_BELOW_A - slowAvg) * voltage3), MAX_POWER);
if (soc != null && soc >= MAX_SOC) { targetW = 0; mode = 'CHARGE-blocked'; }
} else {
mode = 'REST'; modeNum = 0;
targetW = 0;
}
// ---- LOG: one CSV row per run into Simple (Sys) Log (the app adds the timestamp) ----
// columns: grid_A,load_A,batt_W,target_W,soc,mode
if (LOG_ENABLED) {
const row = gridA3.toFixed(2) + ',' + instLoad.toFixed(2) + ',' + Math.round(battW) + ',' + targetW + ',' + (soc == null ? '' : soc) + ',' + modeNum;
try {
await Homey.flow.runFlowCardAction({
uri: 'homey:flowcardaction:homey:app:nl.nielsdeklerk.log:Input_log',
id: 'homey:app:nl.nielsdeklerk.log:Input_log',
args: { log: row }
});
} catch (e) {
log('Sys Log write failed: ' + e.message);
}
}
log(mode + ' | grid ' + gridA3.toFixed(2) + ' A | load ' + instLoad.toFixed(2) + ' A | batt ' + Math.round(battW) + ' W | target ' + targetW + ' W');
// ---- ACT: send to the Zendure (export/stop = instant, charge/rest = throttled) ----
const gentle = (mode === 'CHARGE' || mode === 'CHARGE-blocked' || mode === 'REST');
if (gentle && (now - lastUpdate < MIN_UPDATE * 1000)) return false; // throttled
if (lastSent != null && Math.abs(targetW - lastSent) < DEADBAND_W) return false; // unchanged
// power > 0 = export/discharge, power < 0 = charge from grid
await Homey.flow.runFlowCardAction({
uri: 'homey:flowcardaction:homey:device:' + ZENDURE_ID + ':set-power',
id: 'homey:device:' + ZENDURE_ID + ':set-power',
args: { power: targetW }
});
await global.set('p3_lastUpdate', now);
await global.set('p3_lastSent', targetW);
return true;
A few parts are worth calling out.
Reconstructing the true load is the clever bit. The dongle tells me the net grid current, but the moment the battery starts injecting power, the grid reading no longer reflects what the workshop is actually drawing. Since the Zendure reports its own power — negative when discharging, positive when charging — I subtract that from the grid current to rebuild the real phase-3 load. That reconstructed number is what every decision keys off. Skip this step and the control loop just chases its own tail.
The decision is a small state machine. If the real load is over my limit (13 A, deliberately set well under the 16 A fuse), it exports: target watts equals how far over the limit I am, times the measured voltage, plus a flat feed-forward boost so the battery leads the spike instead of lagging it. If it was exporting and the load just cleared, it stops. If the longer 30-second average is sitting comfortably low, it spends the spare headroom gently recharging the battery. Otherwise it rests.
Two guardrails keep it from thrashing the inverter: a deadband that ignores changes smaller than 25 W, and a throttle on the charge/rest path so only discharge reacts instantly. Discharge is raw and immediate — that’s the entire point. Charging is lazy on purpose. It also writes one CSV row per run to a logging app, which is how I got the clean before/after graphs in the video.
The stress test: 15 bar versus the grid
Same compressor, same tank, filled from about 8 bar all the way to 15 — the full ugly range that normally crosses my line.
Without the battery, demand climbs the whole way up. By the top of the fill the compressor is pulling around 27.5 A. That’s the number that trips the breaker. That’s the number that put oil in my filter.
With the automation running, the compressor still climbs to 27 — but the grid line holds flat where the script pins it, comfortably below the trip point, while the battery quietly stacks the rest on top, up to 2.4 kW of it. The peak still happens. The house just never sees it.
The honest part: losses, heat, and what I still don’t know
There’s no magic here. Conversion has losses, the inverter makes heat you can feel coming off the fins, and the battery drains. This is my own monitored setup, on wiring I understand — it is not a tutorial for ignoring circuit ratings. If what you actually need is a bigger electrical supply, this isn’t that. But a short, recurring peak is exactly the shape of problem a buffer is good at. The losses were never the problem. The peak was the problem, and the peak is gone.
Here’s what it doesn’t fix: the compressor still runs. It sounds fine. But I still don’t know whether that first hard trip — the one that put oil in the filter — did something I can’t see. The filters I could replace. The rotors I just can’t inspect. It works, and I’m choosing to believe that means it’s okay.
So that’s the buffer between my bad habit and the breaker. What’s the machine in your shop that trips yours? The gear I used is listed below — and if you’ve solved a peak like this differently, come tell me in the Discord.