Technical Documentation
A working configuration for a sonnen eco 9.43 battery, Fronius Primo inverter and Amber Electric wholesale pricing
This document describes a complete, working home energy management system built around EMHASS (Energy Management for Home Assistant), a sonnen eco 9.43 battery, a Fronius Primo 6.0-1 solar inverter, and Amber Electric wholesale electricity pricing in Australia.
It is a description of one real installation, not a general tutorial. Where a design decision was made for a specific reason, that reason is given. Where something is a known limitation or an item of work still outstanding, it is labelled as such rather than glossed over.
The document assumes you are comfortable with Home Assistant and have at least passing familiarity with Node-RED. It does not assume you know EMHASS. Concepts are introduced before they are used.
The system is genuinely complex, and reading it end to end in one sitting is unlikely to be productive. A more effective approach is:
EMHASS is developed by David Hernandez (davidusb). This configuration was developed with considerable help from Mark Purcell and from the wider Home Assistant community, particularly the Australian contingent working on Amber Electric integration.
A note on versions. EMHASS development has moved well past the version described here — the current release series is 0.18.x. This document describes a working 0.13.5 installation and is accurate for it, but readers running a recent EMHASS will find that some parameters have been renamed, the default solver has changed, and several of the limitations discussed in Part 3 have been addressed upstream. See Upgrading to a current EMHASS release. - Currency: All prices are Australian dollars per kilowatt-hour ($/kWh) unless stated otherwise. - Times: All times are Australia/Sydney local time. - Battery serial: Sensor names in this document contain the battery serial number
84324. Substitute your own serial number throughout. - Sign convention: Battery power is negative for charge and positive for discharge, matching the EMHASS convention.
Note on completeness. Sections marked Planned or Not yet implemented describe work that is designed but not in production. They are included because they form part of the intended architecture and because the reasoning behind them is useful.
The household is on Amber Electric, which passes through wholesale National Electricity Market (NEM) prices. Those prices move constantly, and in Australia they can be:
A battery and a few shiftable loads can exploit that spread — but only if something decides, ahead of time, when to charge, when to discharge, and when to run the pool pump and charge the car. That “something” is EMHASS.
EMHASS is best understood as a calculator that sits beside Home Assistant. It holds no state between calls. Each time it is invoked it receives a bundle of forecasts and current readings, runs a linear optimisation, and returns a plan. Home Assistant and Node-RED are responsible for gathering the inputs and for acting on the outputs.
| Component | Responsibility |
|---|---|
| Home Assistant | Device integration, entity state, dashboards, historical data |
| Node-RED | All orchestration: data assembly, API calls, control logic, error handling |
| EMHASS | Optimisation only. Given forecasts, returns a cost-optimal schedule |
| sonnen battery | Executes charge/discharge setpoints via its local REST API |
| Amber Electric | Supplies current and forecast import and export prices |
| Solcast | Supplies forecast PV generation |
All automation logic in this system lives in Node-RED. Home Assistant’s declarative automations are not used for energy management. This is a deliberate architectural choice: the flows are inspectable, the error handling is explicit, and the data transformations required to feed EMHASS are far easier to express and debug in Node-RED than in YAML.
The system runs a rolling Model Predictive Control (MPC) loop every 60 seconds:
naive-mpc-optim endpoint.publish-data
writes the results back into Home Assistant sensors.Because every cycle is a fresh calculation from current data, the system self-corrects. A forecast that turns out to be wrong is simply superseded a minute later. This is the principal advantage of MPC over a once-daily plan.
The chart below shows a typical 24-hour forecast, rendered with the apexcharts-card custom card. The left edge is the present moment; everything to the right is forecast.
FIGURE 2 REQUIRED — ApexCharts 24-hour EMHASS forecast (existing screenshot from the previous version of this document may be reused).
In the example, a price spike is forecast for 16:30, reaching over $18/kWh for roughly three hours. The optimiser responds by scheduling the battery to discharge as hard as it can through the spike — visible as the battery state-of-charge curve falling steeply during that window.
FIGURE 3 REQUIRED — ApexCharts showing tariff forecasts overlaid with forecast battery state of charge.
EMHASS offers several optimisation strategies. Two are relevant here.
This is the method used in production. MPC recalculates the entire forward plan on every cycle, using the latest available data, and acts only on the first step of that plan. The window is typically the next 24 hours in 30-minute steps.
MPC is run every 60 seconds. Combined with the
alpha/beta weighting described in Reactivity: the alpha and
beta weights, this makes the system responsive to real events —
switching on a kettle produces a battery response within a minute.
Day-ahead runs once, early in the day, and produces a single fixed plan for the following 24 hours. That plan is then published to Home Assistant periodically without recalculation.
Day-ahead is configured but not used in production. It was valuable during development as a simpler comparison case when debugging the MPC pipeline, and it is retained for that purpose. Its accuracy degrades through the day as actual conditions diverge from the morning’s forecast.
| Method | Why not used here |
|---|---|
| Machine-learning load forecaster | Uses the HA Recorder database to predict consumption. Not used; a simpler rolling average is used instead (see Household load forecast). |
| Thermal-model deferrable loads | Models heaters and air conditioners as thermal systems. Not used; the system has no control over household air conditioning. |
| Perfect optimisation | A retrospective best-case calculation, useful only for benchmarking. |
EMHASS communicates its decisions by publishing sensors into Home Assistant. How those sensors are used is defined entirely in Home Assistant and Node-RED — EMHASS itself controls nothing.
Each sensor carries a current value as its state, and the full forward curve as attributes. Those attributes are what the dashboard charts plot.
The power to apply to the battery, in watts.
This is the sensor used in production. It is passed more or less directly into the sonnen API flows, which is why it is the simplest of the available control strategies.
EMHASS models battery charge and discharge as linear — it assumes the battery can sustain its maximum power across the whole state-of-charge range. Real batteries cannot. Charge acceptance tapers as the pack approaches full, and discharge capability collapses as it approaches empty.
If left uncorrected this produces plans the battery cannot execute: EMHASS expects to reach 100% in an hour, the battery takes two, and every subsequent calculation inherits the error.
Two mitigations are applied, and both are in use:
An alternative control signal: the forecast state of charge rather than the power. To use it, you compare the current SOC against the forecast SOC 30 minutes out, and calculate the power required to close the gap.
This method is implemented but not used in production. It works, but it adds a layer of arithmetic and an observed half-interval phase offset (see The SOC-following method) without improving on the simpler power-following approach.
A 1,300 W single-speed pool pump. Because the pump has only two states, the value of this sensor is ignored; only the transition between zero and non-zero is used, to switch the pump on and off.
A Tesla charging from a 230 V single-phase wall connector, drawing up to 32 A (7,360 W). This is a genuinely variable load, so the sensor value is used: it is converted to an amperage and sent to the car via the Tesla integration.
There is no architectural limit on the number of deferrable loads. Two are configured here.
The status of the most recent optimisation. Its value is either
Optimal or Infeasible.
This is a critical safety signal. An infeasible result means the optimiser could not find a valid solution, and its outputs must not be acted upon. See Handling infeasible optimisations.
The optimiser is only as good as what it is fed. Four forecast arrays are assembled on every cycle.
Amber Electric resells at wholesale NEM prices and publishes forward price forecasts. The NEM settles on a five-minute basis; Amber historically rebundled this into 30-minute billing periods, and is progressively migrating customers to true five-minute settlement.
This system uses Chris Abberley’s amber2mqtt add-on, which supports five-minute settlement and blends Amber’s own forecasts with AEMO’s estimates. There is a window in each five-minute period where the AEMO estimate is the more accurate of the two, and blending them smooths the transition between periods.
The entities used are:
| Entity | Contents |
|---|---|
sensor.amber_5min_current_general_price |
Current import (supply) price |
sensor.amber_5min_current_feed_in_price |
Current export (feed-in) price |
sensor.amber_30min_forecasts_general_price |
Forward import price forecast, in the Forecasts
attribute |
sensor.amber_30min_forecasts_feed_in_price |
Forward export price forecast, in the Forecasts
attribute |
Two implementation details matter:
input_select.price_attr, allowing the estimate
source to be changed from the dashboard without editing the
template.If you remain on 30-minute settlement, the official Amber
Electric integration is simpler. It provides
sensor.<site>_general_price,
sensor.<site>_feed_in_price,
sensor.<site>_general_forecast and
sensor.<site>_feed_in_forecast.
Templates using these entities remain in
configuration.yaml as rest_command
definitions, but are superseded and not called by the production
flow. They are retained as a fallback and as a worked
example.
FIGURE 4 REQUIRED — Example of Amber tariff forecast data (import and export) as displayed in Home Assistant.
PV output forecasts come from Solcast via the Solcast HACS
integration by BJReplay. The entities used are
sensor.solcast_pv_forecast_forecast_today and
..._tomorrow, whose detailedForecast
attributes carry 30-minute estimates in kW.
Solcast’s free rooftop tier now allows 10 API calls per
day. The integration’s automatic polling must therefore be
disabled (“Disable auto API polling” at configuration time) and polling
driven manually. This system calls
solcast_solar.update_forecasts five times a day — at 06:00,
08:08, 10:09, 12:11 and 14:05 — which consumes exactly the 10-call
allowance, since each call retrieves two days.
The update_forecasts service is used rather than
update_actual_forecast, because only the forward forecast
is needed; retrieving actuals would waste the allowance.
Alternative. EMHASS can retrieve PV forecasts itself from
open-meteo,solcast,solar.forecastor a CSV file. Settingweather_forecast_methodtoopen-meteoand omittingpv_power_forecastfrom the POST removes this dependency entirely. This configuration uses Solcast for historical reasons; a new installation could reasonably start with open-meteo.
EMHASS needs a forecast of household consumption excluding the deferrable loads it controls — otherwise it would double-count the pool pump and the car.
sensor.house_power_consumption_less_deferrables is a
template sensor defined in configuration.yaml. It takes
total house consumption reported by the battery and subtracts:
switch.garage_power_point_l1 is on,
andsensor.ynot_charger_wattage,
but only when the car’s device tracker reports it is at home.The result is floored at zero.
An availability template guards the sensor: if the
battery’s consumption reading is unknown or
unavailable, the sensor reports unavailable rather than
publishing a misleading number derived from a zero fallback. This
matters because the value feeds the FIFO buffer, where a spurious zero
would persist in the load profile for 24 hours.
The EV term is derived from an emulated power figure rather than a measurement. See Emulated charge power for why, and what it costs.
EMHASS can build this forecast itself from the Home Assistant Recorder database, averaging the previous two days. This works, but it is only as reliable as the Recorder database, and Recorder databases have a habit of growing until they become slow or corrupt. A large, unmanaged Recorder database is one of the more common causes of EMHASS problems reported in the community.
This system instead maintains its own 24-hour load profile in an input_text
entity called input_text.fifo_buffer, holding 48
comma-separated half-hourly averages.
The mechanism is a first-in, first-out buffer:
sensor.house_power_consumption_less_deferrables is read and
added to a running total.input_text.fifo_buffer and the new average appended to the
end.At POST time, the current instantaneous consumption
is prepended to the buffer as the first element of
load_power_forecast. Combined with alpha: 1,
this makes the optimiser react to what the house is doing right now —
approximating the self-consumption behaviour the battery would otherwise
provide, which is unavailable while the battery is held in manual
mode.
Limitation. An
input_textentity is capped at 255 characters. With 48 values plus separators, the average entry must stay around four characters. Sustained consumption above 9,999 W in many half-hour periods would overflow the buffer. This has not occurred in practice but is a real constraint on the design.
FIGURE 5 REQUIRED — ApexCharts showing Solcast PV forecast overlaid with the load forecast from the FIFO buffer.
The number of forward price intervals available from Amber varies through the day. Amber and AEMO publish a fresh 24-hour forecast from about 12:30 each day, giving 48 half-hour intervals. These are updated until roughly 03:30, after which no new intervals are added — so the available horizon shrinks steadily until the next publication. The shortest horizon occurs around 12:00, at approximately 31 intervals.
The prediction_horizon passed to EMHASS is therefore
calculated at runtime as the length of the shortest of the four
forecast arrays, minus one. Taking the minimum across all four
arrays rather than just the price array protects against a short or
stale Solcast or load array producing a mismatched request.
Ausgrid, the local network operator, applies a demand tariff. Within a defined window, the single highest 30-minute consumption reading in the month is recorded, multiplied by the demand rate and by the number of days in the month, and added to the bill.
The consequence is that a single careless half hour — charging the battery hard from the grid at 5 p.m. on one day of the month — can cost more than a month of ordinary energy charges. The forecast import price does not reflect this at all, so the optimiser has no way to see the risk.
The workaround is to lie to the optimiser. During
the demand window the import price passed in
load_cost_forecast is artificially raised to $1.00/kWh
wherever the real forecast price is below that. The optimiser then
avoids grid import in that window as a matter of ordinary economics.
The window applied is:
Prices already above $1.00/kWh are left untouched, so genuine price spikes are still visible and still exploitable.
Two EMHASS parameters control how much weight is given to present conditions versus the forecast:
alpha — weight on the first (current)
value of the PV and load arrays. Set to 1.beta — weight on the remaining
forecast values. Set to 0.The defaults are 0.5 and 0.5. Pushing alpha to 1 and beta to 0 makes the optimiser strongly prefer measured reality over forecast for the immediate step, which is what produces the near- instant response to a kettle or a cloud. The forward plan is still built from the forecast; it is only the immediate decision that is dominated by the present.
The other limiting factor on responsiveness is the 60-second cycle. Reducing it would increase responsiveness at the cost of CPU load and API traffic.
| Property | Value |
|---|---|
| Model | sonnen eco 9.43 |
| Nominal capacity | 15 kWh |
| Capacity configured in EMHASS | 13,950 Wh |
| Nominal charge / discharge power | 3,300 W |
| Chemistry | LFP |
| API | sonnenBatterie JSON API v2, local, over HTTP |
| Local address | 192.168.99.168 |
Two behavioural characteristics matter operationally:
The PV inverter is a Fronius Primo 6.0-1. It is not currently controlled by this system. All battery control is achieved through the sonnen battery’s own internal inverter, which is sufficient for everything except PV curtailment.
Modbus TCP access to the Fronius is available and is the intended path for curtailment. See PV curtailment.
| Property | Value |
|---|---|
| Modules | 17 × CSUN295-60M |
| Nominal array capacity | 5.0 kWp |
| Strings | 1 |
| Azimuth | 10° |
| Tilt | 21° |
Amber Electric passes through wholesale NEM prices and publishes forward forecasts, which is what makes price-responsive optimisation possible. The document assumes an Amber account with either the official integration or the amber2mqtt add-on installed and working.
Home Assistant runs as Home Assistant OS 18.0 in a virtual machine under Proxmox on an Intel NUC.
Once a household depends on this system to manage its electricity, the platform stops being a hobby installation. It needs deliberate backup, and ideally a documented recovery path. A failure that leaves the battery in manual mode with a stale setpoint is worse than having no automation at all — which is precisely why the resilience measures in Resilience and health monitoring exist.
The Recorder database is not on the critical path in this configuration, because the load forecast is built independently (see The rolling-average FIFO buffer). It is nonetheless actively managed, because an unmanaged Recorder database is one of the more common causes of a Home Assistant installation becoming slow.
The configuration:
purge_keep_days: 10 — a short
retention window, appropriate given that no forecast depends on long
history.input_text.fifo_buffer explicitly
included. It is an input_text, a domain not
otherwise in the include list, and losing the rolling load profile
across a restart would degrade the forecast until 24 hours of samples
had rebuilt.sensor.amber_billing_interval_forecasts_* and
sensor.amber_30min_forecasts_*. These carry large forecast
arrays as attributes and update every few minutes. Recording them bloats
the database quickly and serves no purpose, since the arrays are
consumed live.See the Recorder documentation for the full option set.
Before anything else, verify the battery is not under external control from a virtual power plant or a sonnen tariff product such as sonnen flat or sonnen connect. If Amber is the retailer this is almost certainly already the case.
To confirm: log into the battery’s local web interface with the user
account (the factory user password is sonnenUser3552), open
Settings, and check that you can change the operating
mode between Automatic self-consumption and
Manual, and back again. That ability is the foundation
of everything that follows. Leave it in Automatic for now.
Time-of-use mode is not used in this configuration.
Still logged into the battery, open Software Integration in the menu. The JSON API tab shows the API token for your battery, and below it the sonnenBatterie JSON API v2 documentation.
That token authorises every command Node-RED sends. Treat it as a credential.
Security note. In this configuration the token is written literally into each Node-RED function node. That is convenient but it means the token is present in plain text in every flow export. Flow exports should not be shared, committed to a repository or attached to a support thread without redacting it first. Storing the token once in a Node-RED environment variable and referencing it as
env.get('SONNEN_TOKEN')would remove the exposure and make rotation a one-line change.
FIGURE 7 REQUIRED — Screenshot of the sonnen Software Integration page showing the JSON API tab (with the token redacted).
Six calls cover everything this system needs.
| Purpose | Method | Endpoint | Payload |
|---|---|---|---|
| Backup buffer 0% | PUT | /api/v2/configurations |
EM_USOC=0 |
| Backup buffer 100% | PUT | /api/v2/configurations |
EM_USOC=100 |
| Automatic (self-consumption) | PUT | /api/v2/configurations |
EM_OperatingMode=2 |
| Manual mode | PUT | /api/v2/configurations |
EM_OperatingMode=1 |
| Charge setpoint | POST | /api/v2/setpoint/charge/<watts> |
— |
| Discharge setpoint | POST | /api/v2/setpoint/discharge/<watts> |
— |
All calls require two headers:
Auth-Token: <your token>
Content-Type: application/x-www-form-urlencoded
Three behavioural rules follow from the API design:
EM_OperatingMode=1) before charge or discharge setpoints
are accepted.EM_OperatingMode=2) is
ordinary self-consumption. This is the safe fallback state whenever the
system is uncertain.Eleven switches expose control to the Home Assistant dashboard, implemented with the HACS Node-RED Companion integration. This integration lets Node-RED expose entities into Home Assistant, and lets Home Assistant enable or disable individual Node-RED nodes — which is how whole flows are switched on and off from the dashboard.
These five map to battery API calls:
| Switch | Entity | Payload |
|---|---|---|
| Sonnen Standby | switch.sonnen_standby |
0 |
| Sonnen Manual Mode | switch.sonnen_manual_mode |
— |
| Sonnen Discharge | switch.sonnen_discharge |
Slider, 0 to 3300 |
| Sonnen Charge | switch.sonnen_charge |
Slider, 0 to −3300 |
| Sonnen Automatic Mode | switch.sonnen_automatic_mode |
— |
Each is an HA Switch node with both Enable input and Output on state change ticked.
FIGURE 8 REQUIRED — Node-RED HA Switch node configuration panel.
The five switches behave as a set of mutually exclusive push buttons. Selecting one triggers a Call Service node for each of the other four, turning them off.
This is purely cosmetic — it dims the other icons on the dashboard so the current mode is visually obvious. It has no effect on the battery. Turning a switch off by hand likewise does nothing except dim its own icon.
The wiring uses link out / link in node pairs so that the connections do not clutter the canvas. A one-second delay node sits in front of each call service node; it is almost certainly unnecessary and is retained only because it is harmless.
FIGURE 9 REQUIRED — Node-RED canvas showing the five manual control switches and their link-out wiring.
The remaining switches enable or disable entire Node-RED flows rather than sending commands:
| Switch | Effect |
|---|---|
| SOC Forecast Control | Enables the sensor.soc_batt_forecast control path
(experimental) |
| Power Forecast Control | Enables the sensor.p_batt_forecast control path
(production) |
| Tesla Deferrable Load | Enables EV charge control from
sensor.p_deferrable1 |
| Pool Deferrable Load | Enables pool pump control from
sensor.p_deferrable0 |
| Follow the Sun | Experimental solar-only EV charging. Deprecated, not in use |
| Battery Polling | Detects and corrects battery disengagement. Deprecated — see below |
Battery Polling was written to address a fault in
which the sonnen battery would silently stop following its setpoint.
Every five minutes it compared actual battery power against
sensor.p_batt_forecast and re-applied the setpoint on a
mismatch. It is retained but deprecated; the error-handling
infrastructure described in Resilience and health
monitoring addresses the same class of problem more directly.
FIGURE 10 REQUIRED — Home Assistant dashboard card showing all eleven control switches, with one mode active and the others dimmed.
Each of the six API calls is implemented as a function node that builds the request, feeding an http request node that sends it.
The function node sets the payload, headers and URL on the message object. For example, automatic mode:
msg.payload = "EM_OperatingMode=2"
msg.headers = {}
msg.headers['Auth-Token'] = 'YOUR SONNEN API TOKEN'
msg.headers["Content-Type"] = "application/x-www-form-urlencoded"
msg.url = "http://YOUR_BATTERY_IP:80/api/v2/configurations"
return msg;The other configuration calls (EM_OperatingMode=1,
EM_USOC=0, EM_USOC=100) are identical apart
from the payload.
The charge setpoint receives a negative value from EMHASS and must send a positive magnitude:
var pospayload = 0.0
pospayload = Math.abs(msg.payload)
msg.headers = {}
msg.headers['Auth-Token'] = 'YOUR SONNEN API TOKEN'
msg.headers["Content-Type"] = "application/x-www-form-urlencoded"
msg.url = "http://YOUR_BATTERY_IP/api/v2/setpoint/charge/" + pospayload.toString()
return msg;Discharge is identical but posts to
/api/v2/setpoint/discharge/.
Charge curve correction (optional). A variant adds 100 W to charge requests in the 200–2500 W band, compensating for a measured shortfall in the battery’s actual charge acceptance across that range:
var pospayload = Math.abs(msg.payload)
// Compensate for measured charge shortfall in the mid range
if (pospayload >= 200 && pospayload <= 2500) {
pospayload += 100;
}
// ... headers and URL as above/configurations) use
PUT and return HTTP 200 on
success./setpoint/...) use
POST and return HTTP 201 on
success.A named debug node sits at the end of each branch. These can be disabled once the system is stable.
Link-in nodes and short delay nodes sit in front of the request flows to avoid hitting the battery with several simultaneous HTTP requests. The battery’s web server is modest and does drop requests under concurrent load.
This is a pragmatic rather than an elegant solution. A proper request queue with serialised dispatch would be better, and is on the improvement list.
A single Node-RED switch node (the generic yellow one, not the Home Assistant switch) routes the battery power decision to the correct API call:
| Condition | Route |
|---|---|
payload < 0 |
Manual mode, then charge setpoint |
payload > 0 |
Manual mode, then discharge setpoint |
payload == 0 |
Standby (backup buffer 100%) |
Its input comes from sensor.p_batt_forecast in
production. It also accepts input from
sensor.soc_batt_forecast (experimental path), the
deprecated battery-polling flow, and a manual inject node used for
testing.
Each of the three routes also fires the “switch everything off” service calls, dimming all manual control icons on the dashboard. This reflects reality: while EMHASS is driving, any manual setting will be overridden within a minute.
Standby is achieved by setting the backup buffer to 100%, not by sending a zero setpoint. Sending zero to the charge or discharge endpoint does not reliably hold the battery. The zero setpoint is sent anyway, harmlessly, alongside the buffer change.
FIGURE 11 REQUIRED — Node-RED switch node configuration showing the three routing rules.
The MPC flow is a straight line of six nodes:
msg.url,
msg.method and msg.headers for the
optimisation POST.http://<emhass>:5000/action/naive-mpc-optim.msg.payload becomes {}).http://<emhass>:5000/action/publish-data.Both HTTP request nodes feed error-handling function nodes, described in Resilience and health monitoring.
Why Node-RED rather than Home Assistant automations? Clearer error handling, easier iteration, and far more tractable data transformation. The equivalent
rest_commanddefinitions still exist inconfiguration.yamlbut are not called.
FIGURE 12 REQUIRED — Node-RED canvas showing the 60-second MPC POST and publish flow.
The template is the part that takes the longest to get right. Two tools make it manageable:
The full production template is in Appendix B — Production MPC template.
| Key | Source | Notes |
|---|---|---|
load_cost_forecast |
Amber import price | Current price prepended; demand-window override applied |
prod_price_forecast |
Amber feed-in price | Current price prepended; forecast values sign-inverted |
pv_power_forecast |
Solcast today + tomorrow | Current PV production prepended; kW converted to W |
load_power_forecast |
input_text.fifo_buffer |
Current consumption prepended |
Each array has the current measured value prepended as
element zero. This is what alpha: 1 then weights
heavily.
| Key | Value | Notes |
|---|---|---|
prediction_horizon |
Calculated | Shortest of the four arrays, minus one |
num_def_loads |
2 | Pool pump and EV |
def_total_hours |
Calculated | See Deferrable load scheduling |
P_deferrable_nom |
[1300, <ev_amps × 230>] |
EV limit set from input_number.ev_amps |
P_deferrable_min |
[0, 230] |
EV will not be asked to charge below 1 A |
treat_def_as_semi_cont |
[1, 0] |
Pump is on/off; EV is continuously variable |
set_def_constant |
[0, 0] |
Loads may start more than once per day |
soc_init |
Current USOC ÷ 100 | From sensor.sonnenbatterie_84324_state_charge_user |
soc_final |
0.08 |
Target SOC at the end of the horizon |
alpha / beta |
1 / 0 |
See Reactivity: the alpha and beta weights |
battery_charge_power_max |
Calculated | See Dynamic battery power limits |
battery_discharge_power_max |
Calculated | See Dynamic battery power limits |
Important. Almost every value in the EMHASS base configuration file can be overridden per call in the POST body. In this system most of the operationally significant values — prices, SOC targets, power limits, deferrable hours — are overridden at runtime. The base configuration file is therefore largely inert. Changing a value there and expecting a behaviour change is a common and frustrating mistake. See the annotation in Appendix A — EMHASS base configuration.
treat_def_as_semi_cont is easy to read backwards:
1 (true) — the load is
fixed: it either runs at its nominal power or not at
all. The pool pump.0 (false) — the load is
variable: EMHASS may specify any power between the
minimum and nominal. The EV.The power limits passed to EMHASS are recalculated on every cycle from current state of charge, compensating for the non-linearity described in The linearity problem:
| User SOC | battery_charge_power_max |
battery_discharge_power_max |
|---|---|---|
| Above 81% | 850 W | 3,300 W |
| 8% – 81% | 3,300 W | 3,300 W |
| Below 8% | 3,300 W | 1,200 W |
The upper threshold reflects the battery’s measured charge-acceptance taper above roughly 80%. The lower threshold protects against the discharge collapse near empty.
Open item. These thresholds are hard-coded from observation. If the sonnen API exposes BMS-advertised instantaneous charge and discharge limits, those values should be read and used directly instead — they would track pack temperature and cell condition, which fixed thresholds cannot. See Investigate BMS-advertised power limits.
def_total_hours is calculated live for both loads.
Hours are allocated only when energy is cheap — either PV production above 2,000 W with an import price below $0.12/kWh, or an import price below $0.085/kWh regardless of production. When that condition is met, hours are set by season:
| Season | Hours |
|---|---|
| Winter | 1 |
| Summer | From input_number.pool_pump_hours |
| Spring / Autumn | 3 |
When the price condition is not met, zero hours are allocated and the pump does not run.
Hours are allocated only when the car is at home and the charge cable is connected. The requirement is then derived from the charging gap:
hours = (charge_limit − current_battery_level) ÷ 30 × 3
This scales the allocated charging time to the actual energy needed, so a nearly-full car is not allocated a full charging session.
A server-state-changed node watches
sensor.p_deferrable0. A transition to a non-zero value
turns the smart power point on; a transition to zero turns it off. The
value itself is discarded — the pump is single-speed.
The power point is a power-monitoring smart switch, which is what
allows its consumption to be subtracted in
sensor.house_power_consumption_less_deferrables.
The EV path is more involved, because the charge rate is variable and the Tesla API is both slow and metered.
The flow, triggered by a change in
sensor.p_deferrable1:
Threshold check. Values above 200 W (roughly 1 A) are treated as a charge request; below that, charging is stopped.
Wake and refresh. The car is woken and its state refreshed, so the subsequent calculation uses current data.
Location check. Confirm
device_tracker.ynot_location reports
home.
Cable check. Confirm
binary_sensor.ynot_charge_cable is on.
Amperage calculation. Convert the requested watts to amps using the measured charger voltage, and clamp to the charger’s maximum:
min(max_charge_current, p_deferrable1 ÷ max(220, charger_voltage))Step-change filter. If the newly calculated amperage equals the previous request, the message is dropped. This prevents a stream of identical API calls every minute.
Command. Set the charging amps and start charging.
Tesla’s Fleet API is billed. A separate flow manages polling cadence against a daily budget:
sensor.p_deferrable1 > 0): poll every 5 minutes.Typical usage is 120–160 polls per day, comfortably within the free monthly credit.
sensor.ynot_charger_current reports the
requested amperage rather than the current actually
being delivered, and sensor.ynot_charging has been observed
to latch in a stale state. Deriving EV power from voltage × current
therefore fed unreliable figures straight into
sensor.house_power_consumption_less_deferrables, and from
there into the load forecast.
The current workaround is to emulate the charge power from the commanded amperage rather than measure it:
amps × 240
and writes the result to
input_number.ynot_charger_emulated_power.sensor.ynot_charger_wattage derives from that helper,
and is what sensor.house_power_consumption_less_deferrables
now subtracts.This is deliberately a stopgap, and it is documented as one in
configuration.yaml. It trades measurement for a known-good
command value: the figure is stable and never stale, but it will not
reflect the car tapering its own charge rate, or charging being
interrupted at the vehicle. The 240 V constant is likewise nominal
rather than measured.
Open item. A reliable measurement of actual delivered charge power would be better than emulation. The most promising options are a metering circuit at the wall connector, or a Tesla telemetry field that proves trustworthy under test.
FIGURE 13 REQUIRED — Node-RED canvas of the Tesla deferrable load flow.
FIGURE 14 REQUIRED — Node-RED canvas of the pool pump deferrable load flow.
This is the part of the system that has grown most since the previous version of this document, and it is the part that determines whether the household notices when something breaks.
The design principle throughout: when in doubt, put the battery into automatic self-consumption mode. Self-consumption is never catastrophic. A stale manual setpoint can be.
EMHASS reports an infeasible calculation by setting
sensor.optim_status to Infeasible. The outputs
of an infeasible calculation are meaningless and must not be acted
upon.
A dedicated flow watches this sensor. On a transition to
Infeasible it:
The same flow reverses all three steps when the status returns to
Optimal, so recovery is automatic and requires no
intervention.
FIGURE 15 REQUIRED — Node-RED canvas of the Optimal/Infeasible handling flow.
The sonnen API is the single point of failure for battery control, and it fails in several distinguishable ways. A structured error-handling subsystem sits across all six API request nodes.
A Node-RED catch node (“Sonnen write errors”) is scoped to all six battery HTTP request nodes. Every failure is routed to a classify and count function node, which maintains state across failures.
The classifier examines the error text and applies a grace period appropriate to the likely cause, rather than alarming on the first failure:
| Condition | Grace period | Rationale |
|---|---|---|
| Reboot signature during 00:00–04:00 | 60 minutes | Overnight controller reboot window |
| Reboot signature at any other time | 20 minutes | Unexpected reboot; likely to self-recover |
| Any other error | 5 minutes | Transient network or load-related failure |
The reboot signature is any of
ECONNREFUSED, EHOSTUNREACH,
services not running, HTTP 502 or
HTTP 401. In practice a controller reboot presents as
ECONNREFUSED followed by EHOSTUNREACH — a
distinctive sequence worth recognising, because it is benign and
self-resolving.
The sonnen controller’s nightly reboot window has been confirmed as 00:00 to 04:00 Sydney time. Because Sydney observes daylight saving, the wall-clock window shifts relative to UTC through the year; the classifier uses local hours, so this is handled correctly.
The classifier tracks, in flow context:
| Variable | Contents |
|---|---|
sonnen_fail_streak |
Consecutive failure count |
sonnen_fail_since |
Timestamp of the first failure in the current streak |
sonnen_prev_err |
Most recent error text |
sonnen_unhealthy |
Latch, preventing repeated alerts for one outage |
sonnen_error_log |
Rolling log of the last 50 failures, with timestamp, streak, duration and error text |
The node’s status badge is updated on every failure to show the streak count and elapsed downtime, so the state is visible at a glance on the Node-RED canvas.
When elapsed downtime exceeds the applicable grace period and the unhealthy latch is not already set, two things happen:
input_boolean.sonnen_api_healthy is turned
off.The latch ensures exactly one notification per outage rather than one per minute.
Every successful API call routes through a “Sonnen OK” link-in node, which:
input_boolean.sonnen_api_healthy
on.input_datetime.sonnen_api_last_ok with the
current time.input_datetime.sonnen_api_last_ok is the more useful of
the two helpers for diagnosis: it answers “how long has this been
broken?” directly, and it survives a Node-RED restart.
Two older function nodes remain in place, checking HTTP status codes directly and writing timestamped failures to a log file:
var status = msg.statusCode
if (status != 200) { // 201 for POST setpoint calls
var timestamp = new Date().toISOString();
msg.payload = timestamp + msg.payload;
return msg
}
else
returnThese predate the catch-node infrastructure and overlap with it. They are retained because the file log is convenient for after-the-fact review, but consolidating the two mechanisms is on the improvement list.
EMHASS itself runs locally, but the price and PV forecasts do not. Without Amber and Solcast data the optimiser is working from stale or empty arrays, and its output should not be trusted.
Connectivity is tested every 60 seconds by an
is online node, feeding a state-machine function node that
applies asymmetric hysteresis: it is slow to fail over
and slower still to come back.
| Transition | Threshold | Rationale |
|---|---|---|
| Online to fallback | 15 minutes continuously offline | Long enough to ride out ordinary ISP glitches |
| Fallback to normal | 5 minutes continuously online | Confirms the link is genuinely stable before resuming |
The asymmetry is deliberate. A brief outage costs nothing if it is ignored; a flapping connection that repeatedly toggles the battery between manual and self-consumption mode is genuinely harmful.
When the 15-minute threshold is crossed:
EM_OperatingMode=2) via the
“Automatic Mode Switch” link.switch.emhass_fsoc_control,
switch.emhass_fpow_control,
switch.tesla_deferrable_load,
switch.pool_deferrable_load,
switch.battery_polling,
switch.follow_sun_2.When connectivity has held for 5 minutes:
Note the asymmetry in the switch lists.
switch.emhass_fsoc_controlis turned off on failover but is not turned back on during recovery. This is correct: the SOC-following path is experimental and should not be silently re-enabled by an automated recovery. Only the production power-following path resumes.
The state machine holds three flow variables —
internet_fallback, down_since and
up_since — and updates the node’s status badge
continuously, showing online, down N min, or
recovering N min. The badge makes the current state visible at
a glance on the canvas without opening a debug pane.
The notification latches are implicit in the state variables: exactly one notification is sent per transition, not one per poll.
Self-consumption is a genuinely good fallback. The battery will charge from surplus PV and discharge to cover house load with no external data at all. The household loses arbitrage revenue during the outage but nothing worse.
FIGURE 16 REQUIRED — Node-RED canvas of the internet outage state machine.
Commanding the battery is not the same as knowing what it is doing. Three read-back mechanisms close that loop.
The /api/v2/status endpoint is polled every 5
minutes. The response carries a
dischargeNotAllowed field, which is exposed to Home
Assistant as binary_sensor.dischargenotallowed.
This is a BMS-advertised signal, and it is significant: it is the battery telling the system directly that it will not discharge, whatever setpoint it is sent. Without it, a BMS inhibit looks identical to a battery that is simply ignoring commands.
The handler is defensive. It checks that the response is an object
and that the field is present before using it; if the API returns an
HTML error page instead of JSON — which happens during the controller’s
reboot window — it logs a warning and publishes null rather
than a misleading false.
A manually triggered flow reads
/api/v2/configurations/EM_OperatingMode and translates the
raw value into a human-readable state, published as a Home Assistant
sensor:
| Raw value | Reported state |
|---|---|
1 |
Manual Control |
| Anything else | Self-consumption |
This answers the question “is the battery actually in the mode I think it is?” — which matters after an outage failover, after a controller reboot, or when a manual setpoint appears to have no effect.
Manually triggered GET flows exist for all seven local API endpoints, each terminating in a named debug node:
/status · /io · /battery ·
/configurations · /inverter ·
/latestdata · /powermeter
Only /status is polled automatically. The remainder are
diagnostic tools — trigger the inject node and read the debug output.
They are the starting point for the BMS investigation described in Investigate
BMS-advertised power limits.
LFP chemistry requires a periodic full charge for cell balancing. If the system does not schedule one, the battery management system will force one at a time of its own choosing.
Two flows track this:
On reaching 100%. A
server-state-changed node fires, sends a push notification,
and writes the current date to a file on the Home Assistant share.
Daily at 09:00. The stored date is read, the elapsed
days calculated, and the result published as
sensor.last_full_charge_days. A switch node applies
escalating thresholds:
| Days since full charge | Action |
|---|---|
| 28 or more | Notification — balancing charge overdue |
| 24 to 27 | Notification — balancing charge due soon |
| Under 24 | Sensor updated, no notification |
The 24-day warning exists to give a few days’ notice, so a balancing charge can be scheduled into a cheap or high-solar period rather than being forced by the BMS at an expensive time.
Three further monitoring capabilities are designed but not yet implemented:
A single binary_sensor.energy_system_healthy
aggregating:
input_boolean.sonnen_api_healthysensor.optim_status == 'Optimal'internet_fallback
flagbinary_sensor.dischargenotallowed (inverted)One green light on the dashboard is worth more than six individual indicators, because it is the only one anyone will actually look at.
Stale data is more dangerous than missing data, because nothing errors — the optimiser simply produces confident nonsense. Proposed checks, each raising a warning when the age threshold is exceeded:
| Data source | Expected refresh | Suggested stale threshold |
|---|---|---|
| Solcast forecast | 5× daily | 6 hours |
| Amber price forecast | Every 5 minutes | 15 minutes |
| FIFO load buffer | Every 30 minutes | 45 minutes |
| Battery SOC | Continuous | 5 minutes |
| EMHASS publish | Every 60 seconds | 5 minutes |
The most practical implementation is a template binary sensor
comparing states.<entity>.last_updated against
now() for each source, combined into a single freshness
indicator.
Control devices fail silently. A smart switch that has dropped off the network reports its last known state indefinitely, and the system continues to believe the pool pump is running. Proposed checks:
The general pattern is command-and-verify: after issuing a command, confirm the expected physical consequence appears in telemetry within a reasonable window, and alert if it does not.
Home Assistant dashboards provide the operational view of the system. The custom cards in use are:
| Card | Purpose |
|---|---|
| apexcharts-card | Forecast curves — prices, PV, load, battery SOC |
custom:power-flow-card-plus |
Live energy flow between grid, PV, battery and house |
custom:canvas-gauge-card |
Instantaneous battery power and state of charge |
custom:card-templater |
Templated cards for computed values |
The dashboards have grown organically and are due for a deliberate rework. The intended structure:
Operational view — what is happening right now.
Planning view — what is expected to happen.
Health view — is the system working.
Manual control — override when needed.
FIGURE 17 REQUIRED — Screenshot of the operational dashboard view.
FIGURE 18 REQUIRED — Screenshot of the planning dashboard view with forecast charts.
EMHASS presents a web UI in the Home Assistant sidebar. In normal operation there is no reason to open it: everything is driven by the automated POST cycle.
It remains useful for:
Three items in the EMHASS base configuration are known to be incorrect. They are recorded here because two of them have caused real incidents.
Current value: 0 Recommended
value: 0.10
A minimum of zero permits the optimiser to plan a full discharge. In practice the battery management system trips on its own protection floor before reaching nominal empty — this has already occurred once, on a cold night, when ambient temperature around 13 °C raised cell resistance enough to trigger BMS protection above the nominal floor.
The runtime soc_final of 0.08 provides partial
protection, but it constrains only the endpoint of the horizon, not the
trajectory within it.
Related. The bottom ~5% of user state of charge is an unreliable operating range on this battery and should be treated as unavailable.
Current value: 1000 Correct
value: Array capacity, approximately 5000
This is simply wrong — it does not reflect any real limit in this
system. It is currently inert because compute_curtailment
is false, but it will produce incorrect results the
moment curtailment is enabled. It should be corrected before any
curtailment work begins.
inverter_ac_input_max is also set to 1000 and should be
reviewed at the same time.
Not an error, but a source of persistent confusion worth recording.
The sonnen battery reports two state-of-charge figures:
state_charge_real (RSOC) — the true
cell state of charge.state_charge_user (USOC) — a scaled
figure presented to the user.The gap between them is a permanent built-in reserve, not a fault and not something that can be reclaimed. USOC 0% corresponds to a real cell charge comfortably above empty.
All configuration and control in this system uses
USOC
(sensor.sonnenbatterie_84324_state_charge_user). Mixing the
two — for example setting a floor in RSOC terms and comparing against
USOC — produces errors of several percent in the wrong direction.
Status: Not implemented.
compute_curtailment is false.
Negative feed-in tariffs are increasingly common in Australian summers. When export prices go negative, exporting costs money. The existing mitigations — charging the battery and running deferrable loads — absorb surplus generation up to a point, but once the battery is full and the pump and car have run, remaining generation must go somewhere.
This installation is relatively well balanced: 5 kWp of PV against 15 kWh of storage, a pool pump and an EV. Exposure to negative pricing has therefore been modest, which is why curtailment has not been urgent.
That balance is shifting. Australian regulators are progressively mandating curtailment capability for grid stability, and negative-price periods are lengthening.
The Fronius Primo 6.0-1 supports power limiting over Modbus TCP. The intended design:
inverter_ac_output_max to the
true array capacity — see above. This must be done first or the
optimiser’s curtailment arithmetic will be wrong.compute_curtailment: true, which
causes EMHASS to publish sensor.p_pv_curtailment.The Home Assistant community’s Fronius Modbus TCP integration thread and this curtailment write-up are the best available references.
Design caution. Curtailment should be the last resort in the priority order, after battery charging and after deferrable loads. Throwing away generation is a real economic loss and should only happen when the alternative — paying to export — is worse.
Status: Open investigation.
The dynamic power limits described in Dynamic battery power limits use fixed thresholds derived from observation. They are better than static limits, but they cannot account for temperature, cell ageing, or transient BMS derating.
The sonnen battery’s BMS necessarily knows its own instantaneous limits. The question is whether it exposes them through the local JSON API v2.
One BMS-advertised signal has already been found and put into
service. The dischargeNotAllowed flag on
/api/v2/status is now polled every 5 minutes and exposed as
binary_sensor.dischargenotallowed — see Discharge inhibit flag. That confirms
the API does carry BMS state, not merely configured values, which makes
the rest of this survey considerably more promising.
dischargeNotAllowed is a boolean inhibit rather than a
power figure, so it does not by itself replace the fixed thresholds, but
it is the obvious first input to any dynamic limit: when the flag is
set, the discharge limit is zero regardless of what the thresholds
say.
Endpoints to examine (all under
http://<battery-ip>/api/v2/, with the
Auth-Token header):
| Endpoint | What to look for |
|---|---|
/status |
Summary state; may include current power limits |
/latestdata |
Detailed live telemetry — the most likely candidate |
/battery |
Pack-level data: cell voltages, temperature, BMS state |
/inverter |
Inverter capability and current limits |
/configurations |
Configured maxima, as distinct from instantaneous capability |
/powermeter |
Metering data |
Node-RED flows already exist for GET requests against all of these, each with a named debug node, so the survey requires no new tooling — enable the debug nodes and inspect the output.
What would be useful: any field describing instantaneous maximum charge or discharge power, or the inputs from which it could be derived — pack temperature, cell voltage spread, or an explicit BMS derating flag.
If such fields exist, the template’s hard-coded thresholds should be replaced by the reported values, clamped to the battery’s nominal 3,300 W. This would let the system adapt automatically to cold mornings, hot afternoons and long-term capacity fade.
If they do not, a reasonable fallback is to add a temperature correction to the existing thresholds — pack temperature is available and is the dominant variable.
Practical note. Even where limits are advertised, they may be static configured values rather than live capability. The test is to observe whether the reported figure changes with pack temperature and state of charge. If it does not vary, it is a configuration value and adds nothing over what is already known.
The current pacing arrangement uses fixed delay nodes to avoid overlapping requests. A proper serialised queue with retry and back-off would be more robust and would remove a class of transient errors currently absorbed by the error handler.
The sonnen API token is currently hard-coded in roughly a dozen function nodes. Moving it to a Node-RED environment variable would make rotation trivial and would stop the token leaking into every flow export. See the security note in Step 2 — Retrieve the API token.
Status: Not started. Running 0.13.5; the current series is 0.18.x.
This is deliberately listed as planned work rather than treated as routine maintenance, because the gap is now wide enough that it is a project rather than a version bump.
Several items elsewhere in this document have been addressed upstream since 0.13:
| Open item here | Addressed by |
|---|---|
| Forecast horizon shrinking through the day | Naive-MPC now auto-extends the forecast window to cover the prediction horizon, enabling multi-day Solcast |
| Charge taper above 80% handled by hard-coded thresholds | A high-SoC dwell penalty now discourages sitting at high state of charge |
soc_final constrains only the end of the horizon |
Intermediate SoC targets can now be set for a specific timestep |
| PV curtailment not implemented | Curtailment is now scheduled as late as possible, improving on a naive implementation |
Performance has also improved substantially: the optimisation engine was rewritten using CVXPY and vectorisation in the 0.18 series, with benchmarks reporting roughly 4–5× faster solves.
lp_solver: COIN_CMD; current releases default to
HiGHS.sensor.optim_status stays Optimal and the
published sensors still look sane before moving on.On upgrade fatigue. Chasing every release is neither necessary nor advisable for a system the household depends on. A stable, well-understood version is worth more than the newest one. The risk in staying put is not that any single release is missed — it is that breaking changes accumulate until the upgrade becomes a project. That threshold has now been crossed, which argues for making the jump deliberately and then adopting a slower, chosen cadence: review releases quarterly, upgrade when there is a specific reason, and pin the version in between.
The catch-node infrastructure and the older status-code function nodes overlap. The file-based log should be driven from the classifier rather than maintained separately.
See Smart switch health monitoring. The Tuya power board has been migrated to LocalTuya and the switches operate correctly, but the child-lock data point remains unconfirmed and no verification of switch state against physical effect is currently performed.
Read this first. Most of the operationally significant values below are overridden at runtime by the MPC POST body (see Appendix B — Production MPC template). Changing them here will have no effect on production behaviour. Values marked ⚠ are known to be incorrect.
{
"adjusted_pv_regression_model": "LassoRegression",
"adjusted_pv_solar_elevation_threshold": 10,
"battery_charge_efficiency": 0.95,
"battery_charge_power_max": 3300,
"battery_discharge_efficiency": 0.9,
"battery_discharge_power_max": 3300,
"battery_dynamic_max": 0.9,
"battery_dynamic_min": -0.9,
"battery_maximum_state_of_charge": 1,
"battery_minimum_state_of_charge": 0,
"battery_nominal_energy_capacity": 13950,
"battery_target_state_of_charge": 0,
"compute_curtailment": false,
"continual_publish": false,
"costfun": "profit",
"delta_forecast_daily": 1,
"end_timesteps_of_each_deferrable_load": [0, 0],
"historic_days_to_retrieve": 2,
"inverter_ac_input_max": 1000,
"inverter_ac_output_max": 1000,
"inverter_efficiency_ac_dc": 1,
"inverter_efficiency_dc_ac": 1,
"inverter_is_hybrid": false,
"load_cost_forecast_method": "hp_hc_periods",
"load_forecast_method": "naive",
"load_negative": false,
"load_offpeak_hours_cost": 0.1419,
"load_peak_hour_periods": {
"period_hp_1": [{ "start": "02:54" }, { "end": "15:24" }],
"period_hp_2": [{ "start": "17:24" }, { "end": "20:24" }]
},
"load_peak_hours_cost": 0.1907,
"logging_level": "INFO",
"lp_solver": "COIN_CMD",
"lp_solver_path": "empty",
"lp_solver_timeout": 45,
"maximum_power_from_grid": 14490,
"maximum_power_to_grid": 9000,
"method_ts_round": "first",
"minimum_power_of_deferrable_loads": [0, 0],
"modules_per_string": [17],
"nominal_power_of_deferrable_loads": [1300, 7300],
"num_threads": 0,
"number_of_deferrable_loads": 2,
"open_meteo_cache_max_age": 30,
"operating_hours_of_each_deferrable_load": [2, 1],
"optimization_time_step": 30,
"photovoltaic_production_sell_price": 0.1419,
"production_price_forecast_method": "constant",
"pv_inverter_model": ["Fronius_International_GmbH__Fronius_Primo_6_0_1_208_240__240V_"],
"pv_module_model": ["CSUN_Eurasia_Energy_Systems_Industry_and_Trade_CSUN295_60M"],
"sensor_linear_interp": ["sensor.house_power_consumption_less_deferrables"],
"sensor_power_load_no_var_loads": "sensor.house_power_consumption_less_deferrables",
"sensor_power_photovoltaics": "sensor.sonnenbatterie_84324_production_w",
"sensor_power_photovoltaics_forecast": "sensor.p_pv_forecast",
"sensor_replace_zero": ["sensor.sonnenbatterie_84324_production_w"],
"set_battery_dynamic": true,
"set_deferrable_load_single_constant": [false, false],
"set_deferrable_startup_penalty": [0, 0],
"set_nocharge_from_grid": false,
"set_nodischarge_to_grid": false,
"set_total_pv_sell": false,
"set_use_adjusted_pv": false,
"set_use_battery": true,
"set_use_pv": true,
"set_zero_min": true,
"start_timesteps_of_each_deferrable_load": [0, 0],
"strings_per_inverter": [1],
"surface_azimuth": [10],
"surface_tilt": [21],
"treat_deferrable_load_as_semi_cont": [true, false],
"weather_forecast_method": "open-meteo",
"weight_battery_charge": 0,
"weight_battery_discharge": 0
}| Key | Note |
|---|---|
battery_minimum_state_of_charge: 0 |
⚠ Should be 0.10. See battery_minimum_state_of_charge |
inverter_ac_output_max: 1000 |
⚠ Should be ~5000. Will break curtailment. See inverter_ac_output_max |
inverter_ac_input_max: 1000 |
⚠ Review alongside the above |
costfun: "profit" |
Optimises for financial return, producing frequent arbitrage cycling |
optimization_time_step: 30 |
30-minute intervals, matching the Amber forecast granularity |
compute_curtailment: false |
Curtailment not yet implemented. See PV curtailment |
weather_forecast_method: "open-meteo" |
Inert — PV forecast is supplied in the POST from Solcast |
load_cost_forecast_method,
load_peak_hour_periods, load_*_cost |
Inert — prices are supplied in the POST from Amber |
load_forecast_method: "naive",
historic_days_to_retrieve: 2 |
Inert — load forecast is supplied in the POST from the FIFO buffer |
nominal_power_of_deferrable_loads,
operating_hours_of_each_deferrable_load |
Inert — overridden in the POST |
battery_charge_power_max,
battery_discharge_power_max |
Inert — overridden dynamically in the POST |
inverter_efficiency_ac_dc: 1,
inverter_efficiency_dc_ac: 1 |
Efficiency accounted for in the battery efficiency figures instead |
This is the Jinja2 template evaluated by the “MPC Array” Render
Template node every 60 seconds. It produces the JSON body POSTed to
/action/naive-mpc-optim.
{# Price attribute selected at runtime from the dashboard #}
{% set price_attr = states('input_select.price_attr') %}
{
{#- Feed-in tariff forecast; amber2mqtt values are sign-inverted -#}
"prod_price_forecast": {{
(
[states('sensor.amber_5min_current_feed_in_price')|float(0)]
+ (state_attr('sensor.amber_30min_forecasts_feed_in_price','Forecasts')
| selectattr(price_attr,'is_number')
| map(attribute=price_attr)
| map('multiply', -1)
| list)
) | tojson
}},
{#- Demand tariff months: Nov-Mar and Jun-Aug -#}
{%- set current_month = now().month -%}
{%- if current_month in [11, 12, 1, 2, 3, 6, 7, 8] -%}
"load_cost_forecast":
{%- set current_time = now() -%}
{%- set demand_tariff_start = "15:00:00" -%}
{%- set demand_tariff_end = "21:00:00" -%}
{%- set start_time = current_time.replace(hour=0, minute=0, second=0, microsecond=0) +
timedelta(hours=(demand_tariff_start.split(":")[0]|int),
minutes=(demand_tariff_start.split(":")[1]|int)) -%}
{%- set end_time = current_time.replace(hour=0, minute=0, second=0, microsecond=0) +
timedelta(hours=(demand_tariff_end.split(":")[0]|int),
minutes=(demand_tariff_end.split(":")[1]|int)) -%}
{%- if end_time <= start_time -%}
{%- set end_time = end_time + timedelta(days=1) %}
{%- endif %}
{%- set values = (
[states('sensor.amber_5min_current_general_price')|float(0)]
+ (state_attr('sensor.amber_30min_forecasts_general_price','Forecasts')
| selectattr(price_attr,'is_number')
| map(attribute=price_attr)
| list)
) -%}
{%- set ns = namespace(x=[]) %}
{%- for i in range(values|length) %}
{%- set future_time = current_time + timedelta(minutes=i * 30) %}
{%- if start_time <= future_time < end_time and values[i] < 1 %}
{%- set ns.x = ns.x + [1.0] %}
{%- else %}
{%- set ns.x = ns.x + [values[i]] %}
{%- endif %}
{%- endfor %}
{{- ns.x | tojson }},
{%- else %}
"load_cost_forecast": {{
(
[states('sensor.amber_5min_current_general_price')|float(0)]
+ (state_attr('sensor.amber_30min_forecasts_general_price','Forecasts')
| selectattr(price_attr,'is_number')
| map(attribute=price_attr)
| list)
) | tojson
}},
{%- endif -%}
{#- PV forecast: current production plus Solcast today and tomorrow, kW to W -#}
"pv_power_forecast": {{
(
[states('sensor.sonnenbatterie_84324_production_w')|int(0)]
+ (state_attr('sensor.solcast_pv_forecast_forecast_today', 'detailedForecast')
| selectattr('period_start','gt',utcnow())
| map(attribute='pv_estimate') | map('multiply',1000) | map('int', 0) | list)
+ (state_attr('sensor.solcast_pv_forecast_forecast_tomorrow', 'detailedForecast')
| selectattr('period_start','gt',utcnow())
| map(attribute='pv_estimate') | map('multiply',1000) | map('int', 0) | list)
) | tojson
}},
{#- Load forecast: current consumption plus the 24h FIFO buffer -#}
"load_power_forecast": {{
(
[states('sensor.house_power_consumption_less_deferrables')|int(0)]
+ (states('input_text.fifo_buffer').split(',') | map('int', 0) | list)
) | tojson
}},
{#- Horizon: shortest of the four arrays, minus one.
(Array definitions repeated here for length calculation.) -#}
"prediction_horizon": {{
([prod_price_forecast | length,
load_cost_forecast | length,
pv_power_forecast | length,
load_power_forecast | length] | min) - 1
}},
{#- Deferrable loads -#}
"num_def_loads": 2,
"def_total_hours": [
{#- Pool pump: allocate hours only when energy is cheap -#}
{%- if states('sensor.sonnenbatterie_84324_production_w') | float(0) > 2000
and states('sensor.amber_5min_current_general_price') | float(0) < 0.12
or states('sensor.amber_5min_current_general_price') | float(0) < 0.085 -%}
{%- if is_state('sensor.season', 'winter') -%}
{{ 1 }}
{%- elif is_state('sensor.season', 'summer') -%}
{{ states('input_number.pool_pump_hours') }}
{%- else -%}
{{ 3 }}
{%- endif -%}
{%- else -%}
{{ 0 }}
{%- endif -%},
{#- EV: only when home and plugged in; hours scale with charge gap -#}
{%- if is_state('device_tracker.ynot_location', 'home') -%}
{%- if is_state('binary_sensor.ynot_charge_cable', 'on') -%}
{{ ((states('number.ynot_charge_limit')|int(80)
- (states('sensor.ynot_battery_level')|int(0))) / 30 * 3) | round(0) }}
{%- else -%}
0
{%- endif -%}
{%- else -%}
0
{%- endif -%}
],
"P_deferrable_nom": [1300, {{ (states('input_number.ev_amps') | int(0) * 230) | int(0) }}],
"P_deferrable_min": [0, 230],
"treat_def_as_semi_cont": [1, 0],
"set_def_constant": [0, 0],
"soc_init": {{ (states('sensor.sonnenbatterie_84324_state_charge_user') | int(0)) / 100 }},
"soc_final": 0.08,
"alpha": 1,
"beta": 0,
{#- Dynamic power limits compensating for battery non-linearity -#}
{%- set battery_state = states('sensor.sonnenbatterie_84324_state_charge_user') | int(0) %}
{%- if battery_state > 81 %}
"battery_charge_power_max": 850,
"battery_discharge_power_max": 3300
{%- elif battery_state > 7 %}
"battery_charge_power_max": 3300,
"battery_discharge_power_max": 3300
{%- else %}
"battery_charge_power_max": 3300,
"battery_discharge_power_max": 1200
{%- endif %}
}
The prediction-horizon block in the live template re-declares each of the four arrays as local variables so their lengths can be compared. That repetition is elided above for readability; the definitions are identical to those used in the output.
Retained for development and comparison. Note that it still
references the legacy Amber integration entities
(sensor.cecil_st_*) rather than the amber2mqtt entities
used in production, and would need updating before use.
{
"publish_prefix": "dh_",
"load_cost_forecast": {{
([states('sensor.cecil_st_general_price')|float(0)] +
state_attr('sensor.cecil_st_general_forecast', 'forecasts')|map(attribute='per_kwh')|list)
| tojson
}},
"prod_price_forecast": {{
([states('sensor.cecil_st_feed_in_price')|float(0)] +
(state_attr('sensor.cecil_st_feed_in_forecast', 'forecasts')|map(attribute='per_kwh')|list))
| tojson
}},
"pv_power_forecast": {{
([states('sensor.sonnenbatterie_84324_production_w')|int(0)] +
state_attr('sensor.solcast_pv_forecast_forecast_today', 'detailedForecast')
|selectattr('period_start','gt',utcnow())|map(attribute='pv_estimate')
|map('multiply',1000)|map('int')|list +
state_attr('sensor.solcast_pv_forecast_forecast_tomorrow', 'detailedForecast')
|selectattr('period_start','gt',utcnow())|map(attribute='pv_estimate')
|map('multiply',1000)|map('int')|list
)| tojson
}},
"load_power_forecast": {{
([states('sensor.house_power_consumption_less_deferrables')|int(0)] +
states('input_text.fifo_buffer').split(',') | map('int') | list)
}},
"prediction_horizon": {{
min(48, (state_attr('sensor.cecil_st_feed_in_forecast', 'forecasts')
|map(attribute='per_kwh')|list|length)+1)
}},
"num_def_loads": 2,
"def_total_hours": [3, 1],
"P_deferrable_nom": [1300, 3450],
"treat_def_as_semi_cont": [1, 0]
}
Defined in configuration.yaml under the top-level
template: key. (Note: platform: template
sensors were relocated to this key in current Home Assistant
releases.)
template:
- sensor:
- name: "House Power Consumption Less Deferrables"
unique_id: house_power_consumption_less_deferrables
unit_of_measurement: W
device_class: power
availability: >-
{{ states('sensor.sonnenbatterie_84324_consumption_w')
not in ['unknown','unavailable'] }}
state: >-
{% set consumption = states('sensor.sonnenbatterie_84324_consumption_w') | int(0) %}
{% set pool = states('sensor.garage_power_point_power') | int(0) %}
{% set deferrable0 = pool - 11 if is_state('switch.garage_power_point_l1','on') else pool %}
{% set deferrable1 = states('sensor.ynot_charger_wattage') | int(0)
if is_state('device_tracker.ynot_location','home') else 0 %}
{% set net = consumption - deferrable0 - deferrable1 %}
{{ [net, 0] | max }}The EV term previously used voltage × current from the
Tesla integration. That code remains in configuration.yaml
as a comment, recording why it was replaced — see Emulated charge power.
Converts the commanded amperage to a nominal wattage after the amps are set:
var amps = parseFloat(msg.payload);
if (isNaN(amps)) amps = 0;
msg.payload = Math.max(0, Math.round(amps * 240));
return msg;The result is written to
input_number.ynot_charger_emulated_power. A parallel change
node sets the same helper to zero when charging stops.
Extracts the BMS dischargeNotAllowed flag from
/api/v2/status, defending against non-JSON error
responses:
// Ensure we actually received a JSON response with the expected field
if (msg.payload && typeof msg.payload === "object") {
if ("dischargeNotAllowed" in msg.payload) {
msg.payload = msg.payload.dischargeNotAllowed;
return msg;
}
}
// If the API call failed (e.g. a 502 HTML payload)
node.warn("dischargeNotAllowed not found in API response");
msg.payload = null;
return msg;var mode = msg.payload.EM_OperatingMode
var modeState = ""
if (mode != "1") {
modeState = "Self-consumption";
}
else {
modeState = "Manual Control";
}
msg.payload = modeState
return msg;Polled every 60 seconds. Output 1 fires on failover, output 2 on recovery.
const online = msg.payload === true;
const DOWN = 15 * 60000, UP = 5 * 60000, now = Date.now();
let fb = flow.get('internet_fallback') || false;
if (!online) {
flow.set('up_since', null);
let ds = flow.get('down_since');
if (!ds) { ds = now; flow.set('down_since', ds); }
const m = Math.round((now - ds) / 60000);
node.status({ fill: 'yellow', shape: 'ring', text: 'down ' + m + 'min' });
if (!fb && (now - ds) >= DOWN) {
flow.set('internet_fallback', true);
return [{ payload: { message: 'Internet down 15min - battery to Self-consumption',
title: 'Internet outage' } }, null];
}
return null;
}
flow.set('down_since', null);
if (fb) {
let us = flow.get('up_since');
if (!us) { us = now; flow.set('up_since', us); }
const m = Math.round((now - us) / 60000);
node.status({ fill: 'blue', shape: 'ring', text: 'recovering ' + m + 'min' });
if ((now - us) >= UP) {
flow.set('internet_fallback', false);
flow.set('up_since', null);
return [null, { payload: { message: 'Internet restored - EMHASS resumed',
title: 'Internet outage' } }];
}
return null;
}
flow.set('up_since', null);
node.status({ fill: 'green', shape: 'dot', text: 'online' });
return null;const inputDateStr = msg.payload; // 'YYYY-MM-DD'
if (!inputDateStr || !/^\d{4}-\d{2}-\d{2}$/.test(inputDateStr)) {
node.error("Invalid date format. Expected 'YYYY-MM-DD'", msg);
return null;
}
try {
const inputDate = new Date(inputDateStr);
const currentDate = new Date();
// Normalise both to midnight to avoid time-of-day discrepancies
inputDate.setHours(0, 0, 0, 0);
currentDate.setHours(0, 0, 0, 0);
msg.payload = Math.round(
(currentDate.getTime() - inputDate.getTime()) / (1000 * 60 * 60 * 24));
return msg;
} catch (err) {
node.error("Error calculating date difference: " + err.message, msg);
return null;
}Runs every 60 seconds. Emits on output 1 only on the 30th sample.
var sumHousePower = parseInt(flow.get("flowSumHousePower")) || 0;
var iterationCount = flow.get("flowIterationCount") || 0;
var msg1 = { payload: 0 };
iterationCount = iterationCount + 1;
sumHousePower += parseInt(msg.payload);
flow.set("flowSumHousePower", sumHousePower);
flow.set("flowIterationCount", iterationCount);
if (iterationCount < 30) {
// Still accumulating — report progress on output 2
var msg2 = { payload: [iterationCount, sumHousePower] };
return [null, msg2];
} else {
var averagePower = sumHousePower / 30;
flow.set("flowSumHousePower", 0);
flow.set("flowIterationCount", 0);
msg1.payload = parseInt(averagePower);
flow.set("flowAvgHousePower", msg1.payload.toString());
return [msg1, null];
}// Drop the oldest value, append the newest average
let fiFoBuffer = msg.payload.substring(msg.payload.indexOf(',') + 1);
let avgHousePower = flow.get("flowAvgHousePower");
msg.payload = fiFoBuffer.concat(",".concat(avgHousePower));
return msg;const err = (msg.error && msg.error.message)
? msg.error.message
: (msg.statusCode ? ('HTTP ' + msg.statusCode) : String(msg.error || 'unknown'));
let streak = (flow.get('sonnen_fail_streak') || 0) + 1;
flow.set('sonnen_fail_streak', streak);
let since = flow.get('sonnen_fail_since');
if (streak === 1 || !since) {
since = Date.now();
flow.set('sonnen_fail_since', since);
}
const downMin = (Date.now() - since) / 60000;
// Reboot signature: controller restart, benign and self-resolving
const REBOOT = /ECONNREFUSED|EHOSTUNREACH|services not running|HTTP 502|HTTP 401/i.test(err);
const night = new Date().getHours() < 4;
const graceMin = (REBOOT && night) ? 60 : (REBOOT ? 20 : 5);
flow.set('sonnen_prev_err', err);
node.error('Sonnen fail #' + streak + ' (' + Math.round(downMin) + 'min): ' + err, msg);
let log = flow.get('sonnen_error_log') || [];
log.unshift({ t: new Date().toISOString(), streak: streak,
downMin: Math.round(downMin), err: err });
if (log.length > 50) log = log.slice(0, 50);
flow.set('sonnen_error_log', log);
node.status({ fill: 'red', shape: 'dot',
text: 'fail #' + streak + ' ' + Math.round(downMin) + 'min' });
if (downMin >= graceMin && !flow.get('sonnen_unhealthy')) {
flow.set('sonnen_unhealthy', true);
return [
{ payload: 'off' },
{ payload: { message: 'Sonnen API down ' + Math.round(downMin) + 'min: ' + err,
title: 'Battery API' } }
];
}
return null;var rec = msg.recovered;
msg.payload = {};
if (rec) {
return [msg, { payload: { message: 'Sonnen API recovered', title: 'Battery API' } }];
}
return [msg, null];{{ min(state_attr('number.ynot_charge_current','max'),
states('sensor.p_deferrable1')|int(0)
/ max(220, states('sensor.voltage_ac_fronius_inverter_1_http_192_168_99_232')|int(230))
)|round(0) }}
{%- set third_row = state_attr('sensor.soc_batt_forecast', 'battery_scheduled_soc')[0] -%}
{%- set soc_value = third_row['soc_batt_forecast']|float(0) -%}
{%- set raw_power = ((states('sensor.sonnenbatterie_84324_state_charge_user')|float(0)
- soc_value) / 100 * 15000 / 0.5 / 0.95)|round(0) -%}
{%- set limited_power = min(max(raw_power, -3300), 3300) -%}
{{ limited_power }}
The calculation takes the difference between current SOC and forecast SOC as a percentage, multiplies by pack capacity, divides by the half-hour interval and applies an efficiency factor, then clamps the result to the battery’s power limits.
The template deliberately reads element
[0] of the
battery_scheduled_soc attribute rather than element
[1]. Element [0] is nominally the current
value and [1] the 30-minute forecast, but the published SOC
forecast curve is observed to run half an interval ahead of
itself — so element [0] is in practice the correct
30-minute target.
This is an empirical correction to observed behaviour, not a documented feature, which is one of several reasons this method has not been promoted to production.
FIGURE 19 REQUIRED — ApexCharts showing
soc_batt_forecastoffset relative to actual SOC.
| Entity | Purpose |
|---|---|
sensor.sonnenbatterie_84324_state_charge_user |
User state of charge (USOC) — used throughout |
sensor.sonnenbatterie_84324_state_charge_real |
Real cell state of charge (RSOC) — diagnostic only |
sensor.sonnenbatterie_84324_production_w |
PV production |
sensor.sonnenbatterie_84324_consumption_w |
Total house consumption |
sensor.sonnenbatterie_84324_state_battery_inout |
Battery power, signed |
sensor.sonnenbatterie_84324_state_grid_input |
Grid import power |
sensor.sonnenbatterie_84324_state_grid_output |
Grid export power |
binary_sensor.dischargenotallowed |
BMS discharge inhibit flag, from /api/v2/status |
sensor.last_full_charge_days |
Days since the battery last reached 100% |
Two further sensors are published from Node-RED carrying the battery’s operating mode and its associated message text. See Operating mode read-back.
| Entity | Purpose |
|---|---|
sensor.p_batt_forecast |
Battery power setpoint (negative = charge) — production control |
sensor.soc_batt_forecast |
Forecast battery SOC — experimental control path |
sensor.p_deferrable0 |
Pool pump command |
sensor.p_deferrable1 |
EV charge power command |
sensor.optim_status |
Optimal or Infeasible |
sensor.p_pv_forecast |
Forecast PV production |
| Entity | Purpose |
|---|---|
sensor.amber_5min_current_general_price |
Current import price |
sensor.amber_5min_current_feed_in_price |
Current export price |
sensor.amber_30min_forecasts_general_price |
Import forecast (Forecasts attribute) |
sensor.amber_30min_forecasts_feed_in_price |
Export forecast (Forecasts attribute) |
input_select.price_attr |
Selects which forecast price attribute to use |
| Entity | Purpose |
|---|---|
sensor.solcast_pv_forecast_forecast_today |
Today’s forecast (detailedForecast attribute) |
sensor.solcast_pv_forecast_forecast_tomorrow |
Tomorrow’s forecast |
| Entity | Purpose |
|---|---|
sensor.house_power_consumption_less_deferrables |
Load excluding managed loads |
sensor.garage_power_point_power |
Pool pump power |
switch.garage_power_point_l1 |
Pool pump switch |
device_tracker.ynot_location |
EV location |
binary_sensor.ynot_charge_cable |
EV charge cable connected |
sensor.ynot_battery_level |
EV state of charge |
number.ynot_charge_limit |
EV charge limit |
number.ynot_charge_current |
EV charge current setpoint |
sensor.ynot_charger_voltage |
EV charger voltage |
sensor.ynot_charger_current |
EV charger current (reports set value, not actual) |
sensor.ynot_charging |
EV charging state (has been observed to latch stale) |
input_number.ynot_charger_emulated_power |
Emulated EV charge power, amps × 240 |
sensor.ynot_charger_wattage |
EV power used in the load calculation, from the helper above |
input_number.ev_amps |
Maximum EV charge amps |
input_number.pool_pump_hours |
Summer pool pump hours |
| Entity | Purpose |
|---|---|
input_boolean.sonnen_api_healthy |
Battery API health flag |
input_datetime.sonnen_api_last_ok |
Timestamp of last successful API call |
input_text.fifo_buffer |
48-value rolling load profile (explicitly recorded) |
input_select.price_attr |
Amber forecast price attribute selector |
switch.emhass_fpow_control |
Enable power-following control |
switch.emhass_fsoc_control |
Enable SOC-following control |
switch.tesla_deferrable_load |
Enable EV control |
switch.pool_deferrable_load |
Enable pool pump control |
switch.battery_polling |
Enable battery re-engagement polling (deprecated) |
switch.sonnen_automatic_mode |
Manual: automatic self-consumption |
switch.sonnen_manual_mode |
Manual: manual mode |
switch.sonnen_charge |
Manual: charge |
switch.sonnen_discharge |
Manual: discharge |
switch.sonnen_standby |
Manual: standby |
AEMO — Australian Energy Market Operator. Operates the National Electricity Market and publishes the price forecasts underlying Amber’s data.
Alpha / Beta — EMHASS weighting parameters controlling the balance between current measured values and forecast values.
BMS — Battery Management System. The battery’s internal protection and monitoring controller.
Backup buffer — A sonnen configuration setting reserving a proportion of battery capacity for grid outage backup. Used here as a discharge inhibit.
Deferrable load — An electrical load that can be moved in time without material inconvenience: pool pump, EV charging, hot water.
Demand tariff — A network charge based on the highest half-hour of consumption in a billing period within a defined window, rather than on total energy used.
EMHASS — Energy Management for Home Assistant. The optimisation engine.
Feed-in tariff — The price paid for energy exported to the grid. Can be negative.
Infeasible — An optimisation state in which no valid solution satisfies the constraints. Its output must be discarded.
LFP — Lithium Iron Phosphate. The battery chemistry used in the sonnen eco 9.43. Requires periodic full charge for cell balancing.
MPC — Model Predictive Control. An optimisation approach that recalculates the full forward plan every cycle and acts only on the first step.
NEM — National Electricity Market. The Australian east-coast wholesale electricity market.
RSOC — Real State of Charge. The battery’s true cell state of charge.
Supply tariff / load cost — The price paid for energy imported from the grid.
USOC — User State of Charge. The scaled state of charge presented to the user, offset from RSOC by a permanent reserve.
VPP — Virtual Power Plant. An aggregation scheme in which a third party controls the battery. Must be disabled for local control.