node-red-contrib-home-ems
v0.4.2
Published
Node-RED nodes for a home energy management system: ENTSO-E dynamic prices, PV production forecast, load forecast, and a battery dispatch planner.
Downloads
894
Maintainers
Readme
node-red-contrib-home-ems
Node-RED nodes for a home energy management system: ENTSO-E dynamic prices, PV production forecast, load forecast, and a battery dispatch planner driven by dynamic electricity prices and battery state of charge (SoC).
Six nodes so far:
entsoe-prices— fetches ENTSO-E day-ahead prices, applies a configurable cost model (consumption afname & injection injectie price) and emits the current price plus a forecast for the coming hours. Calls the API sparingly and caches to disk.pv-forecast— forecasts PV production for the next 24–48h from your location and per-string orientation (tilt/azimuth/kWp), via Open-Meteo or Forecast.Solar. Emits current output and an hourly forecast. Caches to disk.load-forecast— measures the intrinsic (uncontrollable) house load from your inverter's power streams, learns a baseload profile (per hour × week/weekend) and forecasts the coming 24–48h as a best / expected / worst band.flex-controller— dispatches controllable loads (EV charger, heat-pump boost, dump loads) against the available surplus: enables them by priority in power steps (7A/15A, 50°/60°) with hysteresis, force-runs a critically-low EV, and reports its power + firm energy back into the loop.battery-planner— decides whether your battery should run in self-use (cover home load) or passive mode (follow an external setpoint to discharge / inject into the grid). Uses prices + the net (production − load) forecast + firm flex demand to sell during expensive hours while reserving SoC when needed.ems-status— a terminal display node: wire any component's output(s) into it and its latest values show under the node (no debug sidebar needed).
All forecast/state outputs plug straight into each other (see Composing).
Early release — the API may still change between
0.xversions.
Install
From Node-RED (Manage palette → Install) search for
node-red-contrib-home-ems, or from the command line in your Node-RED
user directory (~/.node-red):
npm install node-red-contrib-home-emsRequires Node-RED ≥ 3.0 (tested with Node-RED 5.0 / Node.js 22).
The battery-planner node
Inputs — one input, routed by msg.topic
| msg.topic | msg.payload | Purpose |
|-------------|-------------------------------------------------|---------|
| prices | array of { start, value } for the next 24–48h | supply the dynamic price forecast |
| soc | number (percent) | current battery state of charge |
| tick | anything | recompute using stored prices + SoC (e.g. every minute) |
The price field names are configurable and tolerant: it works out of the box
with Nordpool / ENTSO-E / Tibber-style payloads (start/startsAt/time for
time, value/price/total for the price).
Output
The node sets the configured property (default msg.payload) and
msg.topic = "battery-mode":
{
"mode": "passive",
"setpoint": -2500,
"injecting": true,
"soc": 72,
"price": 0.34,
"reason": "injecting @ price 0.34 (threshold:true rank:true)",
"ts": "2026-07-26T18:00:00.000Z"
}mode—self_useorpassive.setpoint— watts; negative = discharge to grid (inject),0= self-use.
Map this downstream to your specific inverter/battery integration (Sungrow, Victron, Sessy, Home Assistant service call, …).
Decision logic
- SoC protection (hysteresis). Below Min SoC, force
self_useand enter a recovery lock; stay in self-use until SoC climbs back to Resume SoC. This stops injecting before the battery runs empty. - Injection strategy (when SoC is healthy) — enable either or both:
- Price threshold — inject when the current price ≥ a fixed value.
- Rank within forecast — inject during the top-N most expensive hours, or above a percentile, over a look-ahead window.
- Combine the two with AND (both must hold) or OR (either holds).
The entsoe-prices node
Fetches ENTSO-E day-ahead prices and turns the raw market price (BELPEX/EPEX, €/MWh) into consumption and injection prices via a configurable cost model.
API usage is minimised
- Results are cached per day and persisted to a file in the Node-RED user directory, so a restart does not re-fetch.
- The API is called only when today is missing, or — after a configurable cutoff hour (default 13:00, when tomorrow's prices are published) — when tomorrow is still missing, with a retry back-off.
- Emitting is decoupled from fetching: an internal Tick (default 60s) or an input message emits from cache without hitting the API.
Input (single, topic-routed)
| msg.topic | Effect |
|------------------------|--------|
| tick / poll / '' | maybe fetch (only if needed), then emit |
| refresh / fetch | force an API call, then emit |
| emit | emit from cache only — never calls the API |
Outputs (two)
- current —
msg.topic = "price/current", payload = the bucket for the current hour (afname/injectie/belpex + breakdown). - forecast —
msg.topic = "prices", payload = array of upcoming hourly buckets, each with avaluefield (per forecast value field). Wire this straight intobattery-planner.
Each bucket:
{
"start": "2026-07-26T18:00:00.000Z",
"belpex_eur_mwh": 65, "belpex_cent_kwh": 6.5,
"energy_cent_kwh": 8.09, "net_cent_kwh": 6.8, "taxes_cent_kwh": 6.76,
"afname_cent_kwh": 21.64, "injectie_cent_kwh": 5.31,
"night": false, "value": 5.31
}Cost model (all editable)
- afname = energy component
(factor × belpex + adder) × multiplier- net tariff (day/night) + fixed taxes/levies.
- injectie =
factor × belpex + adder. - Prices are in c€/kWh (1 €/MWh = 0.1 c€/kWh).
- Neutral defaults = the raw market price (
0.1 × belpex_eur_mwh) with no markups. Plug in your own supplier/grid tariffs. Belgian (Fluvius Limburg + Eneco dynamic) example:energyFactor 0.102, energyAdder 1, energyMultiplier 1.06, netNormal 6.80, netNight 5.63, taxes 6.7571, injectionAdder −1.188.
Token
Set your ENTSO-E securityToken on the node, or via the ENTSOE_TOKEN
environment variable.
The pv-forecast node
Forecasts PV production for the coming hours from your location and per-string orientation. Add one row per string/plane (tilt, azimuth, kWp); an east-west array is simply two rows. Azimuth convention: south = 0, east = −90, west = +90 (same for both providers).
Providers (selectable)
- Open-Meteo — free, no key, generous limits, up to 16 days. Fetches tilted
irradiance (GTI) per string and converts with a simple model:
watts = kWp × GTI × systemEfficiency, capped at the inverter AC max. - Forecast.Solar — PV-specific, returns AC watts directly (the model
coefficients are then ignored). Public tier needs no key; add one (node or
FORECAST_SOLAR_KEYenv) for higher limits/horizon.
One request is made per string and the results are summed. The forecast is re-fetched at most every Refresh hours (default 3) and cached to disk; emitting (internal Tick, or an input message) is decoupled from fetching.
Input / outputs
Same topic routing as entsoe-prices (tick/refresh/emit). Two outputs:
- current —
topic "pv/current", payload for the current hour (watts,wh,wh_rest_today,wh_forecast_total). - forecast —
topic "production", array of upcoming hourly buckets, each withwatts,whand avaluefield (watts).
See examples/pv-forecast.json.
The load-forecast node
Learns and forecasts the intrinsic (uncontrollable) house consumption as a band.
- Measure — send
topic: measurewith instantaneous power (W){ pv, grid_import, grid_export, battery_charge, battery_discharge }; the load is derived by energy balance (load = pv + grid_import + battery_discharge − grid_export − battery_charge). You can also send{ load }directly. - Learn — "certain" scheduled components (pool, hot water, heating) and the current controllable-load power are subtracted from the measured load first, so the learned residual is the clean intrinsic baseload — never polluted by control-activated consumption (see below).
- Forecast — hourly
best/expected/worst(W). Certain components are added to all cases; "opportunistic" loads only widen the worst case. - Steer — turn any load on/off by name with
topic: enable/topic: disable(payload = the component name, e.g."zwembad"). Ortopic: component{ name, enabled?, power?, ... }for a full update, ortopic: componentsto replace the whole list.
Controllable loads stay out
Controllable loads (EV, heat-pump boost, dump loads) are not demand — they
are flexibility, owned by the flex-controller. Feed their current power back
via topic: control (a number or { name: power }), or include it in the
measure payload ({ …, controllable: 3450 } / { …, loads: { ev: 3450 } }), and
it is excluded from learning. This keeps the forecast from reasoning circularly
(forecasting surplus-driven charging as if it were firm demand).
Outputs: current (topic "load/current": load, controllable_power,
intrinsic_load) and forecast (topic "load", buckets with
best/expected/worst and a value = expected). See
examples/load-forecast.json.
The flex-controller node
A balance-regulated controller for the controllable loads. Instead of computing a surplus and allocating it once, it regulates on the measured power balance and nudges the loads incrementally, by priority.
Error signal:
headroom = battery + grid − batteryReserve(soc)battery = + charging / − discharging, grid = + export / − import. Each tick:
headroom above the deadband → raise the highest-priority load a step; below it →
shed the lowest-priority active load first; otherwise hold.
- Assets — an EV uses
modulate(continuous current betweenminAmps/maxAmps, withvoltage/phases) + optionalsocDriven(socWish/socMust/capacityWh/targetSoc). A relay usessteps(e.g.[{"label":"boost","power":1500}]).priority: low = added first, shed last. A critically-low connected EV (soc < socMust) is force-run (firm). - Gradual, paced ramp — a modulating load is raised by
rampAmps(therampAmpsFaststep abovefastStepHeadroom) at a time, and only after a freshbatteryreading, so it waits for the previous change to land before the next. It also won't get ahead of the charger's reportedactualcurrent (commanded 8A but 7A measured → hold). A higher-priority load is filled to its max before the next starts (EV to max, then the heat pump); shedding is immediate. A SoC-driven asset is treated as not connected until aconnected: truearrives — an EV that hasn't reported is never charged. - SoC policy — below
socMinSteerthe battery reserves its full charge power (loads only run on surplus beyond it); between that andsocHighit reserves thechargeBandfirst; at/abovesocHighthe reservation drops. - Inputs —
topic: battery(W, ±),topic: grid(W, ±),topic: soc(home-battery %),topic: carsoc{ name, soc },topic: connected{ name, connected },topic: actual{ name, amps|power },topic: enable/disable. - Outputs — commands (
topic "flex/commands": per-asset{ name, on, amps, step, power, actual, reason }→ wire to your relays/charger) and state (topic "flex/state":headroom,reserve_charge,controllable_power,firm_wh,tier).
Why battery + grid (not grid export alone):
battery + grid = PV − intrinsic − controllable, so the signal excludes the planner's deliberate battery discharge — selling at a good price never shows up as surplus. It also needs no per-device metering: an unmetered heat-pump relay that draws nothing (water already hot) simply doesn't lower the headroom, so the surplus flows to the next load. Example: PV 3000W, house 1000W, planner selling 2500W → grid export is 4500W, butbattery + grid = −2500 + 4500 = 2000W, the real PV surplus.
See examples/flex-controller.json.
The ems-status node
A terminal display node for readable, at-a-glance monitoring — much handier than
debug nodes for following a running flow. Drop one after a component and wire
that component's output(s) into it; its latest values appear under the node.
It auto-detects the channel from msg.topic:
| Component | Status line |
|---|---|
| pv-forecast | now W · rest today · 48h · peak |
| entsoe-prices | afname / injectie · 48h min–max |
| load-forecast | now · intrinsic · flex · forecast peak |
| flex-controller | headroom · active assets · firm energy |
| battery-planner | mode · setpoint · SoC · injection floor |
Wire both outputs of a two-output node into the same status node to see
current and forecast together. See examples/full-ems.json.
For entsoe-prices you can set the price field (auto / afname / injectie /
belpex) — use two status nodes on the forecast output to show afname and
injectie side by side, each with its own 48h min–max.
For load-forecast you can set the load view:
summary— now / 24h total / peak;blocks— expected energy per clock-aligned 6h block (e.g.per 6u 18u:4.4 0u:2.1 6u:5.3 12u:6.0), chronological — handy to see and tune the overnight consumption that drives the planner's reserve floor;sparkline— a 24h bar-chart of the expected load shape.
The battery-planner line also shows the reserve energy behind the floor
(floor 79% (reserve 5.9kWh)).
Composing the nodes
The nodes form a closed loop — outputs carry the topics/fields each next node reads, so wiring is direct (no mapping):
entsoe-prices ─(prices)───────────────┐
pv-forecast ─(production)──┬─────────┤
│ ├─► battery-planner ─► mode
load-forecast ─(load)────────┼─────────┤ ▲
▲ control (controllable_power) │ │ reserve (firm_wh)
└───────────────── flex-controller ─┴──────────┘
▲ surplus / carsoc / enable
└─ commands ─► your relays / EV charger- The planner reserves SoC for a coming deficit (production − load) and for
firm flex demand (
firm_whfrom a critically-low EV), and injects freely when a surplus is expected. flex-controllerfeeds itscontrollable_powerback toload-forecastso learning stays clean, and itsfirm_whto the planner'sreserveinput.
See examples/full-ems.json, or the simpler
examples/entsoe-to-planner.json.
Unit note: the price forecast
valueis in c€/kWh by default. If you enable the planner's fixed price threshold, set it in the same unit (e.g.8, not0.30). The rank strategies (top-N / percentile) are unit-agnostic.
Example wiring
See examples/basic-flow.json — import it via
Menu → Import. It wires an inject node (SoC), a prices source, and a 1-minute
tick into the planner, then a debug node on the output.
Typical sources in a Home Assistant + Node-RED setup:
- SoC — a
current state/events: statenode on your battery sensor → settopictosoc. - Prices — your dynamic-tariff integration (Nordpool, ENTSO-E, Tibber) →
set
topictoprices. - tick — an
injectnode repeating every minute.
Development
npm install
npm test # runs the pure-logic unit tests (node --test)The decision logic lives in lib/planner.js with no Node-RED
dependency, so it is unit-tested in isolation and reusable by upcoming nodes.
License
MIT © io-things
