Home Assistant and Victron GX/Multiplus II: Managing your Battery using Modbus TCP

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.

  1. 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?

  2. 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?

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

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

  2. 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:

  1. 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.
  2. 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:

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

1 Like