First of all a shoutout to @kc_au whose guide to connecting HA and Victrons was what convinced me all this was possible and gave me my start in getting all this set up. There is quite a bit of relevant information in their post that I will not repeat here.
Victron integrated with HA and EMHASS - My Single Guide
My Setup
-
Victron Multiplus II 15kVA model. Grid tied on AC-IN and all loads connected to AC-out 1
-
80kWh of BYD-Flex 48V battery
-
Victron Cerbo-GX
-
Victron EM24 Smart Meter, LAN connected
-
2x Victron Smartsolar MPPT RS 450|200 DC-coupled to battery
-
20.6 kWp of panels conected to Smartsolar
-
1x Fronius Gen24 Primo 10kVA PV Inverter AC-coupled on AC-Out
-
11kWp of panels connected to Fronius
-
Home Assistant running on HAOS on a NUC.
-
HA, Cerbo-GX, Smart meter and Fronius all interconnected by IP. (2 LANs connected by Ubiquiti Nanobeam and static routes. Static leases so hardwired IP addresses stay correct).
-
Grid connection is on a TOU tariff. 9am-4pm: 10c/kWh, 4pm-9pm: 60c/kWh, 9pm-9am: 20c/kWh
-
Feed-in retricted to 4.5kW (5kVA to say it strictly) but a good feed-in tariff of 13.3c/kWh
-
I have enough load such that the 64kWh stored in the battery (80kWh - min soc of 20%) is used up before the sun comes up and I end up buying energy form the grid at the mid rate most days.
Just using the capabilites of the Cerbo-GX this would work OK, but there are a few niggling isues that I address using HA control.
-
On sunny days, especially in summer the battery is full by midday and then my export is restricted and the PV is throtled, wasting energy. Wouldn’t it be cool if I could predict the full battery and start exporting earlier and hence get more bang out of the solar panels?
-
On cloudy days, the batteries do not fill. A kWh that is not in the battery at 4pm will need to be bought overnight at 20c/kWh. Wouldn’t it be nice if I could buy that energy between 9am and 4pm at 10c instead?
-
Victron’s ESS attempts to keep your batteries alive with something called Batterylife. Essentially this tries to fill your batteries as often as possible by increasing the minimum battery SOC at which you switch to grid. Every day when your battery does not fill over a threshold, it gets raised by 5%. When you exceed the threshold your active minimum SOC gets reduced by 5%. This system will certainly preserve your Li Ion batteries, but it was designed around Lead-acid batteries and really tries to fill your batteries most days and operate them between full charge and (full charge - daily consumption).
This is neither necessary nor strictly desirable for Li-ion batteries. For Li-ion batteries you want to operate between some chosen lower limit (at least 20% if you want long battery life) that has enough reserve to get you to sunup (possibly with some loads dropped!) up to an upper limit that suits your consumption. However, you do want to fill your batteries and soak them for an hour or two every fortnight or so to top balance the cells and reset the BMS SOC calculator. If you are running Batterylife it won’t wreck your battery, but if you have a run of cloudy days it will unnecessarily raise your minimum SOC and waste battery capacity when the sun comes out. We can do better.
First Step: Connect to your Cerbo GX using Modbus-TCP. Here’s modbus.yaml that gets included for the modbus integration:
In configuration.yaml:
modbus: !include modbus.yaml
modbus.yaml:
# Configure all modbus devices here
# NOTE IF YOU CHANGE THIS YOU MUST FULLY RESTART HA FOR THE CHANGE TO TAKE EFFECT
- name: victron
type: tcp
host: 192.168.3.40 # IP address of Cerbo GX
port: 502
sensors:
- name: 'Victron ESS Grid Target' # Writeable. This is the value Inverter will attempt to import/export to and from the grid AFTER it takes into account your load and solar.
unit_of_measurement: "W"
slave: 100 #HUB - Slave is your device ID you found when looking in the Device List on the Victron GX.
address: 2700 #ESS Control Loop Set Point
data_type: uint16 # for import, use 0-32768 For export use (65536 - export amount) - Twos complement.
scan_interval: 5
device_class: power
- name: 'Victron Maximum System Grid Feed In' #Writeable
unit_of_measurement: "W"
data_type: uint16
slave: 100 #HUB
address: 2706
scale: 0.01
device_class: power
- name: 'Victron Inverter Max Feed In L1' # Writeable. MaxFeedInPower. I use this one.
unit_of_measurement: "W"
slave: 227
address: 66
data_type: uint16
scale: 100
precision: 0
device_class: power
- name: 'Victron AC L1' # Power supplied by system to loads.
unit_of_measurement: "W"
slave: 100 #HUB
address: 817 #AC Consumption L1
data_type: uint16
scan_interval: 5
device_class: power
- name: 'Victron Grid Load' #Power supplied by grid to system
unit_of_measurement: "W"
slave: 40 # Grid Meter
address: 2600 # Grid L1 - Power
data_type: int16
scan_interval: 5
device_class: power
- name: 'Victron Energy from Grid'
unit_of_measurement: "kWh"
slave: 40 # GRID METER NOTE THE Slave ID differs for different meters. Check the VRM Instance of the device on your GX
address: 2634 # Total Energy From Network
data_type: uint32
scale: 0.01
precision: 1
scan_interval: 5
device_class: energy
state_class: total_increasing
- name: 'Victron Export to Grid'
unit_of_measurement: "kWh"
slave: 40 #GRID METER
address: 2636 # Total Energy to Network
data_type: uint32
scale: 0.01
precision: 1
scan_interval: 5
device_class: energy
state_class: total_increasing
- name: 'Victron Solar power' # DC-coupled PV
unit_of_measurement: "W"
slave: 100 #HUB
address: 850
data_type: uint16
device_class: power
- name: 'Victron ESS Minimum SoC setpoint'
unit_of_measurement: "%"
data_type: uint16
slave: 100 #HUB
address: 2901
scan_interval: 5
scale: 0.1 #scalefactor 10
- name: 'Victron Batterylife Active SOC Limit' # Only meaningful if Batterylife is enabled
unit_of_measurement: "%"
data_type: uint16
slave: 100 #HUB
address: 2903
scan_interval: 5
scale: 0.1 #scalefactor 10
#Battery
- name: 'Victron Battery current' #Positive: charging battery Negative: discharging battery
unit_of_measurement: "A"
slave: 100 #HUB
address: 841
data_type: int16
scale: 0.1
precision: 0
device_class: current
- name: 'Victron Battery Power System' #Positive: Charging
unit_of_measurement: "W"
slave: 100 #HUB
address: 842
data_type: int16
scale: 1.0
precision: 0
device_class: power
- name: 'Victron Charge Power System' #Power supplied by battery to system (Not including DC Loads?)
unit_of_measurement: "W"
slave: 100 #HUB
address: 860
data_type: int16
scale: 10.0
precision: 0
device_class: power
- name: 'Victron Battery State of Charge System'
unit_of_measurement: "%"
slave: 100 #HUB
address: 843
data_type: uint16
scale: 1
precision: 0
- name: 'Victron Inverter AC IN L1 V'
unit_of_measurement: "V"
slave: 227
address: 3
data_type: uint16
scale: 0.1
precision: 1
device_class: voltage
- name: 'Victron Inverter AC IN L1 A' # Positive: Importing power
unit_of_measurement: "A"
slave: 227
address: 6
data_type: uint16
scale: 0.1
precision: 1
device_class: current
- name: 'BYD Battery Voltage'
unit_of_measurement: "V"
slave: 225
address: 259
data_type: uint16
scale: 0.01
precision: 1
device_class: voltage
- name: 'BYD Battery Amperage'
unit_of_measurement: "A"
slave: 225
address: 261
data_type: uint16
scale: 0.1
precision: 1
device_class: current
- name: 'BYD Battery Consumed Amphours' #Always negative (to have the same sign as the current).
unit_of_measurement: "A"
slave: 225
address: 265
data_type: uint16
scale: 0.1
precision: 1
device_class: current
- name: 'BYD Battery State Of Charge' #This is the one I use.
unit_of_measurement: "%"
slave: 225
address: 266
data_type: uint16
scale: 0.1
precision: 0
device_class: battery
- name: 'BYD Battery Capacity'
unit_of_measurement: "Ah"
slave: 225
address: 309
data_type: uint16
scale: 0.1
precision: 1
device_class: current
# FRONIUS INVERTER
- name: 'Fronius Energy'
unit_of_measurement: "kWh"
slave: 20 # PV Inverter - check VRM Instance for device.
address: 1030
data_type: uint16
scale: 0.01
precision: 1
device_class: energy
state_class: total_increasing
- name: 'Fronius Power'
unit_of_measurement: "W"
slave: 20
address: 1029
data_type: uint16
precision: 0
device_class: power
Step 2 in the process is to define a couple of thresholds that vary throughout the day.
-
A lower threshold (behind threshold.) If the battery SOC is below this threshold, then charge it from the grid. Only do this during low-tariff hours, of course.
-
A higher threshold. If the battery SOC is above this threshold, the sun is shining, and there is forecast solar excess, then start exporting power even though the battery is not full.
Use the following sensor templates:
- name: "Ahead Threshold"
unit_of_measurement: "percent"
device_class: battery
state: >
{% set ahead_levels = [100,100,100,100,100,45,50,60,65,70,75,80,85,90,95,100,100,100,100,100,100,100,100,100] %}
{% set hour = now().hour %}
{{ ahead_levels[hour] | int }}
- name: "Behind Threshold"
unit_of_measurement: "percent"
device_class: battery
state: >
{% set behind_levels = [0,0,0,0,0,0,0,0,0,28,38,53,68,78,88,98,0,0,0,0,0,0,0,0,0] %}
{% set hour = now().hour %}
{{ behind_levels[hour] | int }}
Now let’s estimate how much solar excess we have remaining today. The following is really only meaningful during daylight hours, which are the only hours that we’ll be forcing export or import.
The sensor forecasting consumption assumes an average 5 kW consumed during the hours when the sun is shining. The forecast for solar energy remaining today comes from the Forecast.Solar integration.
- name: "Forecast Solar Self Consumption Today"
unit_of_measurement: "kWh"
device_class: energy
state: >
{% set expected_remaining_daylight_consumption = [50,50,50,50,50,50,50,50,50,40,35,30,25,20,15,10,5,0,0,0,0,0,0,0] %}
{% set soc = states ('sensor.byd_battery_state_of_charge') %}
{% set battery_full_capacity = 80 %}
{% set hour = now().hour %}
{{ ((((100 - soc | int) | float * battery_full_capacity | float * 0.01 ) | float) + expected_remaining_daylight_consumption[hour]) | float }}
- name: "Total Forecast Solar Remaining Today"
unit_of_measurement: "kWh"
device_class: energy
state: >
{{ (states ('sensor.energy_production_today_remaining_4') | float + states('sensor.energy_production_today_remaining_2') | float + states('sensor.energy_production_today_remaining_3') |float) | round(1) }}
- name: "Forecast Solar Excess Remaining Today"
unit_of_measurement: "kWh"
device_class: energy
state: >
{{ (states('sensor.total_forecast_solar_remaining_today') | float(0) - states ('sensor.forecast_solar_self_consumption_today') | float) | round (1)}}
- name: "Instantaneous Solar Excess"
unit_of_measurement: "W"
device_class: power
state: >
{{ (states ('sensor.total_solar_power') | int - states ('sensor.ac_loads') | int) }}
Next Check if the battery need balancing.
I defined a timer that counts down from 336 hours (2 weeks) and gets reset back to 2 weeks if the battery SOC is 100% for a whole hour. If the timer ever runs down and becomes idle, it means we should force a battery fill.
In automations:
alias: Restart Battery Fill Countdown
description: ""
trigger:
- platform: numeric_state
entity_id:
- sensor.byd_battery_state_of_charge
for:
hours: 1
minutes: 0
seconds: 0
above: 99
condition: []
action:
- service: timer.finish
data: {}
target:
entity_id: timer.time_to_next_battery_fill
- service: timer.start
metadata: {}
data: {}
target:
entity_id: timer.time_to_next_battery_fill
mode: single
Now we are finally ready to calculate the appropriate grid setpoint!
Note that a couple of the sensors you’ll see in this are not in the modbus definitions - that’s because I’m getting them using MQTT which is what I was using before I decided I wanted to reliably write to registers. Sensors with the same values are available in modbus as well.
Note also that my “do nothing” behaviour is a small export amount. I have different values in different parts of the code so I can easily see which path the logic has chosen from the output result.
# Calculate Grid setpoint.
# Battery SOC is behind the curve OR Battery needs top balancing AND we are in low-tariff time -->> import max
# Battery SOC is ahead of the curve AND we have forecast excess for rest of day AND instantaneous solar excess > 500W --> export portion of solar excess
# Battery SOC is ahead of curve but forecast excess < 3kWh -->> export 100W
# Battery SOC is ahead of the curve and we have forecast excess BUT instantaneoud solar excess < 500W --> export 10W
# Battery SOC is neither ahead of nor behind the curve --> export 20W
- name: "Calculated Grid Setpoint"
unit_of_measurement: "W"
device_class: power
state: >
{% set loads = states ('sensor.ac_loads') %}
{% set production = states ('sensor.total_solar_power') %}
{% set soc = states ('sensor.byd_battery_state_of_charge') %}
{% set forecast_excess = (states ('sensor.forecast_solar_excess_remaining_today')) %}
{% set export_fraction_of_excess = 0.7 %}
{% set legal_export_limit = 4500 %}
{% set battery_needs_balancing = ((states('timer.time_to_next_battery_fill') == "idle") and (states('sensor.behind_threshold') |int > 0)) %}
{% if ((soc | int < states('sensor.behind_threshold') | int ) or battery_needs_balancing) %}
12000
{% elif (soc | int > states ('sensor.ahead_threshold') | int) %}
{% if forecast_excess | int < 3 %}
-100
{%elif states('sensor.instantaneous_solar_excess') | int(0) > 500 %}
{{ min ([(states ('sensor.instantaneous_solar_excess') | float(0) * export_fraction_of_excess) , legal_export_limit ]) * -1 }}
{% else %}
-10
{% endif %}
{% else %}
-20
{% endif %}
Now the calculated setpoint needs to be reformatted to be written out to the register, because what you write to the victron has to be an integer multiple of 10.
- name: "Clean Calculated Grid Setpoint"
unit_of_measurement: "W"
device_class: power
state: >
{% if has_value('sensor.calculated_grid_setpoint') %}
{{ (states ('sensor.calculated_grid_setpoint') | float(0) /10) |int * 10 | int }}
{% else %}
-20
{% endif %}
We now have a value that is ready to be written out to the Victron GX system.
But there is one final hurdle: The number in the Victron GX is a 2s complement representation of the signed integer (negative for export and positive for import.
The following two templates convert in either direction. The first one reads the value from the Victron GX and turns it into a signed integer.
The second one reads a signed integer from a slider and creates an unsigned 2s complement representation suitable for writing out to Victron.
I’ll explain the slider next.
- sensor:
- name: "ESS Grid Setpoint signed" # get setpoint as 2s complement unsigned integer and convert to signed integer
unit_of_measurement: "W"
device_class: power
state: >
{% if states('sensor.victron_ess_grid_target') | int < 32768 %}
{{states ('sensor.victron_ess_grid_target') | int }}
{% else %}
{{ states ('sensor.victron_ess_grid_target') | int - 65536 }}
{% endif %}
- name: "ESS Grid Setpoint unsigned" # Get signed integer from slider and convert to unsigned integer 2s complement
unit_of_measurement: "W"
device_class: power
state: >
{% if states('input_number.input_current_slider') | int < 0 %}
{{states ('input_number.input_current_slider') | int + 65536 }}
{% else %}
{{ states ('input_number.input_current_slider') | int }}
{% endif %}
I use the slider below rather than writing directly to Victron whenever the template calculates a new setpoint for a couple of reasons:
- Sometimes I want to disable the automation writing the setpoint automatically and manually override by setting the slider directly. Very useful for doing things like measuring voltage drops or simply forcing a charge for some reason.
- I don’t want to be constantly updating the modbus register in the Victron GX. Victron takes about 30 seconds to a minute to converge on a setpoint. So to avoid the possibility of instability, I latch the calculated value to the slider every 2 minutes. This causes the converted value to be written out to the Victron GX.
In configuration.yaml:
input_number:
# Input Slider for Grid Setpoint
input_current_slider:
name: ESS Grid Target Value
min: -5000 #update this to your maximum inverter capacity
max: 15000 #update this to your maximum inverter capacity
step: 10 #this is the increment of the slider in watts. It can be as low as increments of 10.
In Automations:
- Every 2 minutes, latch the calculated value to the slider.
alias: Update ESS Grid Setpoint slider from calculation
description: Closes the control loop
trigger:
- platform: time_pattern
minutes: /2
condition: []
action:
- service: input_number.set_value
target:
entity_id: input_number.input_current_slider
data:
value: "{{ states('sensor.clean_calculated_grid_setpoint') | int }}"
mode: single
- Changing the slider causes a converted version to be written out to the Victron GX.
alias: Set Victron ESS Grid Setpoint
description: >-
If the setpoint is changed by the slider or other means, write it out to
Victron GX
trigger:
- platform: state
entity_id:
- sensor.ess_grid_setpoint_unsigned
condition: []
action:
- service: modbus.write_register
data:
address: 2700
hub: victron
slave: 100
value: "{{ states('sensor.ess_grid_setpoint_unsigned') | int }}"
mode: single
Hope you have as much fun playing with this stuff as I am.