Technical Documentation

Home Energy Management with EMHASS

A working configuration for a sonnen eco 9.43 battery, Fronius Primo inverter and Amber Electric wholesale pricing

Robert Cruikshank  |  August 2026 — EMHASS v0.13.5 — Revision 3

About this document

Purpose

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.

Who it is for

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.

How to read it

The system is genuinely complex, and reading it end to end in one sitting is unlikely to be productive. A more effective approach is:

  1. Read Part 1 — How the system works for the concepts and the shape of the whole thing.
  2. Skim Part 2 — Implementation to see where each concept lives in practice.
  3. Return to specific sections in Part 2 and the Appendices as a reference while building or troubleshooting your own configuration.

Acknowledgements

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.

Status and conventions

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.

Part 1 — How the system works

System overview

What the system is trying to do

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.

The role of each component

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.

Division of responsibilities between components
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 control loop

The system runs a rolling Model Predictive Control (MPC) loop every 60 seconds:

  1. Gather. A Jinja2 template assembles current readings and forecasts into a single JSON object.
  2. Optimise. That object is POSTed to the EMHASS naive-mpc-optim endpoint.
  3. Publish. A second POST to publish-data writes the results back into Home Assistant sensors.
  4. Act. Node-RED flows watch those sensors and translate them into battery setpoints and deferrable-load commands.
  5. Repeat 60 seconds later with fresh data.

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.

System architecture. External services feed Home Assistant; Node-RED orchestrates everything and is the only component that writes to hardware; EMHASS optimises but controls nothing directly. The dashed path to the Fronius inverter is planned, not implemented.

Example: responding to a price spike

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.

Optimisation methods

EMHASS offers several optimisation strategies. Two are relevant here.

Model Predictive Control (MPC)

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 optimisation

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.

Methods not used

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.

The EMHASS output sensors

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.

sensor.p_batt_forecast

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.

The linearity problem

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:

  1. Avoid the non-linear region. The lower bound is set so that the battery is not discharged below approximately 8% user state of charge.
  2. Adjust the power limits dynamically. The maximum charge and discharge powers passed to EMHASS are varied according to current state of charge. This is done in the Jinja2 template at the moment of the API call, so it reflects the battery’s actual condition every cycle. See Dynamic battery power limits.

sensor.soc_batt_forecast

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.

sensor.p_deferrable0 — pool pump

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.

sensor.p_deferrable1 — electric vehicle

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.

sensor.optim_status

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.

Forecast inputs

The optimiser is only as good as what it is fed. Four forecast arrays are assembled on every cycle.

Electricity prices — Amber Electric

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.

Current implementation: amber2mqtt

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:

Amber price entities supplied by amber2mqtt
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:

Legacy implementation: the Amber HA integration

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.

Solar generation forecast — Solcast

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.forecast or a CSV file. Setting weather_forecast_method to open-meteo and omitting pv_power_forecast from the POST removes this dependency entirely. This configuration uses Solcast for historical reasons; a new installation could reasonably start with open-meteo.

Household load forecast

EMHASS needs a forecast of household consumption excluding the deferrable loads it controls — otherwise it would double-count the pool pump and the car.

The base sensor

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:

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.

Why not use the built-in method

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.

The rolling-average FIFO buffer

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:

  1. Every 60 seconds, the current value of sensor.house_power_consumption_less_deferrables is read and added to a running total.
  2. On the 30th sample — that is, every 30 minutes — the average is calculated, the accumulator is reset, and the average is written to a flow variable.
  3. The oldest value is trimmed from the front of 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_text entity 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.

Forecast horizon

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.

Pricing complications

Demand tariffs

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.

The Ausgrid demand tariff window, and how the system avoids it.

Reactivity: the alpha and beta weights

Two EMHASS parameters control how much weight is given to present conditions versus the forecast:

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.

Part 2 — Implementation

Hardware and services

Battery — sonnen eco 9.43

sonnen battery specification
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:

Inverter — Fronius Primo 6.0-1

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.

Solar array

PV array specification
Property Value
Modules 17 × CSUN295-60M
Nominal array capacity 5.0 kWp
Strings 1
Azimuth 10°
Tilt 21°

Electricity retailer — Amber Electric

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 platform

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.

Recorder database

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:

See the Recorder documentation for the full option set.

Gaining control of the battery

Step 1 — Confirm the battery is not in a VPP

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.

Step 2 — Retrieve the API token

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).

The API commands used

Six calls cover everything this system needs.

sonnen local API calls used by this system
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:

Node-RED control layer

Manual control switches

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.

Direct battery commands (switches 1–5)

These five map to battery API calls:

Manual battery control switches
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.

Mode-selection behaviour

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.

Flow-enable switches (switches 6–11)

The remaining switches enable or disable entire Node-RED flows rather than sending commands:

Flow-enable switches
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.

The API call flows

Each of the six API calls is implemented as a function node that builds the request, feeding an http request node that sends it.

Function node pattern

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.

Charge and discharge setpoints

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

HTTP request node settings

A named debug node sits at the end of each branch. These can be disabled once the system is stable.

Request pacing

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.

Routing the EMHASS decision

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:

Battery command routing
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

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 cycle

Flow structure

The MPC flow is a straight line of six nodes:

  1. Inject node — fires every 60 seconds.
  2. Render Template node (“MPC Array”) — evaluates the Jinja2 template that builds the JSON request body. This is the heart of the configuration.
  3. Change node — sets msg.url, msg.method and msg.headers for the optimisation POST.
  4. HTTP request node — POSTs to http://<emhass>:5000/action/naive-mpc-optim.
  5. Change node — resets the message for the publish POST (msg.payload becomes {}).
  6. HTTP request node — POSTs to 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_command definitions still exist in configuration.yaml but are not called.

FIGURE 12 REQUIRED — Node-RED canvas showing the 60-second MPC POST and publish flow.

Developing the template

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.

What the template sends

Forecast arrays

Forecast arrays in the MPC POST
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.

Runtime parameters

Runtime parameters in the MPC POST
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.

The semi-continuous flag

treat_def_as_semi_cont is easy to read backwards:

Dynamic battery power limits

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:

Dynamic battery power limits by state of charge
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.

Deferrable load scheduling

def_total_hours is calculated live for both loads.

Pool pump

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:

Pool pump runtime allocation
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.

Electric vehicle

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.

Deferrable load control

Pool pump

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.

Electric vehicle

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:

  1. Threshold check. Values above 200 W (roughly 1 A) are treated as a charge request; below that, charging is stopped.

  2. Wake and refresh. The car is woken and its state refreshed, so the subsequent calculation uses current data.

  3. Location check. Confirm device_tracker.ynot_location reports home.

  4. Cable check. Confirm binary_sensor.ynot_charge_cable is on.

  5. 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))
  6. 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.

  7. Command. Set the charging amps and start charging.

Tesla API polling strategy

Tesla’s Fleet API is billed. A separate flow manages polling cadence against a daily budget:

Typical usage is 120–160 polls per day, comfortably within the free monthly credit.

Emulated charge power

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:

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.

Resilience and health monitoring

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.

Handling infeasible optimisations

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:

  1. Turns off the EMHASS control switches, disconnecting the optimiser from the battery.
  2. Turns off the deferrable load control switches.
  3. Switches the battery to automatic self-consumption mode.

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.

Battery API error handling

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.

Structure

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.

Classification and grace periods

The classifier examines the error text and applies a grace period appropriate to the likely cause, rather than alarming on the first failure:

Error grace periods by classification
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.

State maintained

The classifier tracks, in flow context:

Error-handling state variables
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.

Actions

When elapsed downtime exceeds the applicable grace period and the unhealthy latch is not already set, two things happen:

  1. input_boolean.sonnen_api_healthy is turned off.
  2. A push notification is sent, giving the outage duration and the error text.

The latch ensures exactly one notification per outage rather than one per minute.

Recovery

Every successful API call routes through a “Sonnen OK” link-in node, which:

  1. Turns input_boolean.sonnen_api_healthy on.
  2. Stamps input_datetime.sonnen_api_last_ok with the current time.
  3. Sends a recovery notification — but only if the system had previously been marked unhealthy.

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.

Legacy status-code checks

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
    return

These 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.

Internet outage handling

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.

Internet outage state machine thresholds
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.

On failover

When the 15-minute threshold is crossed:

On recovery

When connectivity has held for 5 minutes:

Note the asymmetry in the switch lists. switch.emhass_fsoc_control is 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.

State tracking

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.

Battery state and mode verification

Commanding the battery is not the same as knowing what it is doing. Three read-back mechanisms close that loop.

Discharge inhibit flag

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.

Operating mode read-back

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:

Operating mode read-back mapping
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.

Diagnostic endpoint survey

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.

Battery full charge tracking

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:

Full charge reminder 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.

Health monitoring roadmap

Three further monitoring capabilities are designed but not yet implemented:

Combined health indicator

A single binary_sensor.energy_system_healthy aggregating:

One green light on the dashboard is worth more than six individual indicators, because it is the only one anyone will actually look at.

Data freshness monitoring

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:

Proposed data freshness thresholds
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.

Smart switch health monitoring

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.

Dashboards

Home Assistant dashboards provide the operational view of the system. The custom cards in use are:

Custom dashboard cards in use
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

Dashboard design principles

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.

The EMHASS web interface

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:

Part 3 — Known issues and planned work

Configuration corrections required

Three items in the EMHASS base configuration are known to be incorrect. They are recorded here because two of them have caused real incidents.

battery_minimum_state_of_charge

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.

inverter_ac_output_max

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.

RSOC versus USOC

Not an error, but a source of persistent confusion worth recording.

The sonnen battery reports two state-of-charge figures:

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.

Planned work

PV curtailment

Status: Not implemented. compute_curtailment is false.

The case for it

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.

Implementation approach

The Fronius Primo 6.0-1 supports power limiting over Modbus TCP. The intended design:

  1. Enable Modbus TCP on the Fronius inverter (Settings > Modbus).
  2. Add a Modbus integration in Home Assistant addressing the inverter’s power-limit register.
  3. Correct 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.
  4. Set compute_curtailment: true, which causes EMHASS to publish sensor.p_pv_curtailment.
  5. Add a Node-RED flow that reads that sensor and writes the corresponding limit to the inverter register.
  6. Fail safe. If the Modbus write fails or the sensor becomes stale, the limit must be released back to 100%. A curtailment command that sticks after the price recovers is a costly failure mode — worse than never curtailing at all.

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.

Investigate BMS-advertised power limits

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):

sonnen API endpoints to survey for BMS power limits
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.

Request queue for battery API calls

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.

Move the API token to an environment variable

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.

Upgrading to a current EMHASS release

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.

Why it is worth doing

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.

Why it is not trivial

Suggested approach

  1. Read the changelog for every release between the installed version and the target, noting only the breaking changes.
  2. Pin the add-on to a specific version tag rather than tracking latest, so the upgrade happens when chosen rather than automatically.
  3. Upgrade one minor version at a time, confirming sensor.optim_status stays Optimal and the published sensors still look sane before moving on.
  4. Re-validate the MPC template against the new parameter names at each step.
  5. Take a full Home Assistant backup first. The rollback path is reinstalling the previous version tag.

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.

Consolidate error handling

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.

Smart switch verification

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.

Appendices

Appendix A — EMHASS base configuration

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
}

Annotations

Configuration annotations
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

Appendix B — Production MPC template

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.

Appendix C — Day-ahead template

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]
}

Appendix D — Supporting templates and code

House consumption less deferrable loads

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.

EV charge power emulation

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.

Discharge inhibit flag handler

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;

Operating mode read-back

var mode = msg.payload.EM_OperatingMode
var modeState = ""
if (mode != "1") {
    modeState = "Self-consumption";
}
else {
    modeState = "Manual Control";
}
msg.payload = modeState
return msg;

Internet outage state machine

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;

Days since last full charge

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;
}

FIFO buffer — 30-minute averaging

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];
}

FIFO buffer — shift and append

// 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;

Error classification and counting

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;

Recovery marking

var rec = msg.recovered;
msg.payload = {};
if (rec) {
    return [msg, { payload: { message: 'Sonnen API recovered', title: 'Battery API' } }];
}
return [msg, null];

EV amperage calculation

{{ 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) }}

SOC-following template (experimental, not in production)

{%- 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 SOC-following method

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_forecast offset relative to actual SOC.

Appendix E — Entity reference

Battery — sonnen integration

Battery entities
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.

EMHASS outputs

EMHASS-published entities
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

Pricing — amber2mqtt

Amber pricing entities
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

Solar forecast

Solcast entities
Entity Purpose
sensor.solcast_pv_forecast_forecast_today Today’s forecast (detailedForecast attribute)
sensor.solcast_pv_forecast_forecast_tomorrow Tomorrow’s forecast

Deferrable loads

Deferrable load entities
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

Health and control helpers

Health and control entities
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

Appendix F — Glossary

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.

Appendix G — Further reading

Further reading
Resource Link
EMHASS documentation https://emhass.readthedocs.io/en/latest/
Home Assistant https://www.home-assistant.io/
sonnen custom integration https://community.home-assistant.io/t/custom-integration-sonnenbatterie/181781
Solcast HACS integration https://github.com/BJReplay/ha-solcast-solar
amber2mqtt add-on https://github.com/cabberley/amber2mqtt-addon
Amber Electric HA integration https://www.home-assistant.io/integrations/amberelectric/
Tesla custom integration https://github.com/alandtse/tesla
Node-RED Companion https://github.com/zachowj/hass-node-red
apexcharts-card https://github.com/RomRider/apexcharts-card
HA Recorder documentation https://www.home-assistant.io/integrations/recorder/
Fronius Modbus TCP thread https://community.home-assistant.io/t/integration-of-a-fronius-symo-gen-24-plus-inverter-via-modbus-tcp/264577
Amber curtailment write-up https://www.smartmotion.life/2023/09/12/amber-electric-curtailment-with-home-assistant/
Five-minute settlement explained https://help.amber.com.au/hc/en-us/articles/4411273786637-Five-minute-settlement-explained
JSON validator https://jsonlint.com/