I have a Growatt SPH6000 hybrid inverter connected to 2 strings of solar panels and a Growatt GBLI6532 battery. Originally I used the supplied Growatt Shine-S stick (diverting via grott) to get the lovely data out of it, but I didn’t like having an external dependency, so I decided to try and replace it with an ESP32 running ESPHome.

Here Be Dragons

This is my setup that works for me. There is no guarantee that if you try this, it won’t burn your house down. It may make you fall in love with a penguin and give you nightmares about circus midgets.

Hardware

In a burst of caution, I’m not going to give detailed wiring instructions as a skill filter. If you don’t know how to connect this up, you really shouldn’t be messing with this stuff. The fundamentals are an ESP32 powered via a voltage converter that is connected to a pin on the RS232 socket that appears to give out circa 9-12v. An RS232 level shifter then feeds into the serial port of the ESP32. I spent a fair time with a Raspberry Pi, 2 USB-RS232 cables and a hacked together cable to sniff the traffic between the Growatt and the Shine-S stick to figure out how Growatt told the inverter to do things. These two PDFs helped decode what went on.

Software

substitutions:
  devicename: SPH
  update_fast: 10s
  update_slow: 30s

script:
  - id: setClock
    then:
    - lambda: |-
        esphome::modbus_controller::ModbusController *controller = id(growatt);
        time_t now = ::time(nullptr);
        struct tm *time_info = ::localtime(&now);
        int seconds = time_info->tm_sec;
        int minutes = time_info->tm_min;
        int hour = time_info->tm_hour;
        int day = time_info->tm_mday;
        int month = time_info->tm_mon + 1;
        int year = time_info->tm_year;
        year = year + 1900;
        // if there is no internet connection localtime returns year 70
        if (year != 1970) {
          // create the payload
          std::vector<uint16_t> rtc_data = {uint16_t(year-2000), uint16_t(month), uint16_t(day), uint16_t(hour),uint16_t(minutes),uint16_t(seconds)};
          // Create a modbus command item with the time information as the payload
          esphome::modbus_controller::ModbusCommandItem set_rtc_command =
              esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller, 45, rtc_data.size(), rtc_data);
          // Submit the command to the send queue
          controller->queue_command(set_rtc_command);
          ESP_LOGI("ModbusLambda", "Growatt RTC set to %02d:%02d:%02d %02d/%02d/%04d", hour, minutes, seconds, day, month, year);
        }



  - id: setAllTimes
    then:
    - lambda: |-
        esphome::modbus_controller::ModbusController *controller = id(growatt);
        std::vector<uint16_t> disable_data = {0,0,0};
        std::vector<uint16_t> enable_data  = {0,23*256+59,1};
        std::vector<uint16_t> window_data  = {0,23*256+59,0};
        std::vector<uint16_t> morning_data = {2*256+0,5*256+0,1};

        std::vector<uint16_t> charge_rates = {100,100,1};
        int size = window_data.size();

        ESP_LOGI("ModbusLambda","Enqueue Writes");
        
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1090,charge_rates.size(),charge_rates));

        //GF1
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1080,size,disable_data));
        //GF2
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1083,size,disable_data));
        //GF3
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1086,size,disable_data));

        //BF1
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1100,size,morning_data));
        //BF2
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1103,size,disable_data));
        //BF3
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1106,size,disable_data));

        //LF1
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1110,size,disable_data));
        //LF2
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1113,size,disable_data));
        //LF3
        controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1116,size,disable_data));

        ESP_LOGI("ModbusLambda","Writes");


esphome:
  name: growatt
  friendly_name: Growatt
  on_boot:
    priority: -100
    then:
      - delay: 15s
      - logger.log: "************* In da boot *******************"
      - script.execute:
          id: setClock
      - script.execute:
          id: setAllTimes
     

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:
  baud_rate: 0 
  logs:
      json: ERROR
      sensor: ERROR
      modbus_controller.sensor: ERROR
      component: ERROR


# Enable Home Assistant API
api:
  encryption:
    key: "<SECRET>"


ota:
  platform: esphome
  password: "<SECRET>"

wifi:
  ssid: !secret ios_wifi_ssid
  password: !secret ios_wifi_password
  domain: .wifi
  
  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Growatt Fallback Hotspot"
    password: "<SECRET>"

captive_portal:
    

web_server:
  local: True
  port: 80
  version: 3
#  include_internal: true

mqtt:
  broker: alfred.lan
  username: !secret ios_mqtt_user
  password: !secret ios_mqtt_password

time:
  - platform: homeassistant
    id: homeassistant_time
    on_time:
      - seconds: 0
        minutes: 0
        hours: 10
        then:
          - script.execute:
              id: setClock

uart:
  id: mod_bus
  tx_pin: 1
  rx_pin: 3
  baud_rate: 9600
#  debug:


modbus:
  id: modbus1
  uart_id: mod_bus
  
modbus_controller:
  - id: growatt
    ## the Modbus device addr
    address: 0x1
    modbus_id: modbus1
    setup_priority: -10  
    update_interval: ${update_slow}


button:
  - platform: template
    name: "Battery First"
    on_press:
      then:
          lambda: |-
            esphome::modbus_controller::ModbusController *controller = id(growatt);
            std::vector<uint16_t> on={0,23*256+59,1};
            std::vector<uint16_t> off={0,23*256+59,0};
            int size = on.size();

            ESP_LOGI("ModbusLambda","Enqueue Writes");
            //BF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1100,size,on));
            //LF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1110,size,off));
            //GF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1080,size,off));
            ESP_LOGI("ModbusLambda","Writes");

  - platform: template
    name: "Load First"
    on_press:
      then:
          lambda: |-
            esphome::modbus_controller::ModbusController *controller = id(growatt);
            std::vector<uint16_t> on={0,23*256+59,1};
            std::vector<uint16_t> off={0,23*256+59,0};
            int size = on.size();

            ESP_LOGI("ModbusLambda","Enqueue Writes");
            //BF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1100,size,off));
            //LF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1110,size,on));
            //GF1
            controller->queue_command(esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(controller,1080,size,off));
            ESP_LOGI("ModbusLambda","Writes");

text_sensor:
  # - name: "dump"
  #   platform: modbus_controller
  #   address: 1080
  #   register_count: 40
  #   raw_encode: HEXBYTES
  #   register_type: "holding"

  - platform: template
    name: "${devicename} Status"
    icon: mdi:eye
    entity_category: diagnostic
    lambda: |-
      if ((id(inv_status).state) == 1) {
        return {"Normal"};
      } else if ((id(inv_status).state) == 0)  {
        return {"Standby"};
      } else if ((id(inv_status).state) == 2)  {
        return {"Discharge"};
      } else if ((id(inv_status).state) == 3)  {
        return {"Fault"};
      } else if ((id(inv_status).state) == 4)  {
        return {"Flash"};
      } else if ((id(inv_status).state) == 5)  {
        return {"PV Charging"};
      } else if ((id(inv_status).state) == 6)  {
        return {"AC Charging"};
      } else if ((id(inv_status).state) == 7)  {
        return {"Combined Charging"};
      } else if ((id(inv_status).state) == 8)  {
        return {"Combined Charging & Bypass"};
      } else if ((id(inv_status).state) == 9)  {
        return {"PV Charging & Bypass"};
      } else if ((id(inv_status).state) == 10)  {
        return {"AC Charging & Bypass"};
      } else if ((id(inv_status).state) == 11)  {
        return {"Bypass"};
      } else if (id(inv_status).state == 12)  {
        return {"PV Charge and Discharge"};
      } else {
        return {"Unknown"};
      }



select:
  - platform: modbus_controller
    name: "${devicename} AC Charging"
    icon: mdi:battery-charging-100
    address: 1092
    value_type: U_WORD
    optionsmap:
      "Disabled": 0
      "Enabled": 1

  - platform: modbus_controller
    name: "${devicename} Inverter Priority"
    icon: mdi:arrow-decision-outline
    address: 1044
    value_type: U_WORD
    optionsmap:
      "Load First": 0
      "Battery First": 1
      "Grid First": 2       

sensor:
  - platform: modbus_controller
    name: "${devicename} Inverter Status"
    address: 0
    register_type: "read"
    icon: mdi:home-lightning-bolt
    value_type: U_WORD
    id: inv_status
    internal: true

    
  - platform: modbus_controller
    name: "${devicename} Input Power"
    address: 1
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:solar-power-variant
    value_type: U_WORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} PV1 Power"
    id: pv1_power
    address: 5
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 0
    internal: true    
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} PV2 Power"
    id: pv2_power    
    address: 9
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 0
    internal: true
    filters:
    - multiply: 0.1        

  - platform: template
    id: pv_power
    name: "${devicename} PV Power"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:solar-power-variant
    accuracy_decimals: 0
    lambda: |-
      return float((id(pv1_power).state + id(pv2_power).state));
    update_interval: ${update_fast}


  - platform: template
    name: "${devicename} Output Power to grid"
    id: ogrid_power
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:solar-power-variant
    accuracy_decimals: 0
    lambda: |-
      return max(0.0f,float((id(out_power).state - id(charge_power).state - id(invlocal_power).state)));
    update_interval: ${update_fast}

  - platform: modbus_controller
    name: "${devicename} Output Power"
    id: out_power
    address: 35
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:home-lightning-bolt
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    id: pv1_energy_today
    name: "${devicename} PV1 Energy Today"
    address: 59
    register_type: "read"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 1
    internal: true    
    filters:
    - multiply: 0.1
  
  - platform: modbus_controller
    id: pv1_energy_total  
    name: "${devicename} PV1 Energy Total"
    address: 61
    register_type: "read"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 1
    internal: true    
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    id: pv2_energy_today
    name: "${devicename} PV2 Energy Today"
    address: 63
    register_type: "read"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 1
    internal: true    
    filters:
    - multiply: 0.1
  
  - platform: modbus_controller
    id: pv2_energy_total
    name: "${devicename} PV2 Energy Total"
    address: 65
    register_type: "read"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    value_type: U_DWORD
    accuracy_decimals: 1
    internal: true    
    filters:
    - multiply: 0.1

  - platform: template
    id: pv_energy_today
    name: "${devicename} PV Energy Today"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    accuracy_decimals: 1
    lambda: |-
      return float((id(pv1_energy_today).state + id(pv2_energy_today).state));
    update_interval: ${update_slow}

  - platform: template
    id: pv_energy_total
    name: "${devicename} PV Energy Total"
    unit_of_measurement: kWh
    device_class: energy
    state_class: total_increasing
    icon: mdi:solar-power-variant
    accuracy_decimals: 1
    lambda: |-
      return float((id(pv1_energy_total).state + id(pv2_energy_total).state));
    update_interval: ${update_slow}

  - platform: modbus_controller
    name: "${devicename} Inverter Temperature"
    address: 93
    register_type: "read"
    unit_of_measurement: °C
    device_class: temperature
    state_class: measurement    
    icon: mdi:thermometer
    value_type: U_WORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1  

  - platform: modbus_controller
    name: "${devicename} Battery Discharging Power"
    address: 1009
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    icon: mdi:battery-arrow-down
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1    

  - platform: modbus_controller
    name: "${devicename} Battery Charging Power"
    address: 1011
    id: charge_power
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    icon: mdi:battery-arrow-up-outline
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1 

  - platform: modbus_controller
    name: "${devicename} Battery SoC"
    address: 1014
    register_type: "read"
    unit_of_measurement: "%"
    icon: mdi:home-battery
    value_type: U_WORD
    accuracy_decimals: 0

  - platform: modbus_controller
    name: "${devicename} AC Power to User Total"
    address: 1021
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:transmission-tower-export
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} AC Power to Grid Total"
    address: 1029
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement
    icon: mdi:transmission-tower-import
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} INV Power to Local Load Total"
    id: invlocal_power
    address: 1037
    register_type: "read"
    unit_of_measurement: W
    device_class: power
    state_class: measurement    
    icon: mdi:home-import-outline
    value_type: U_DWORD
    accuracy_decimals: 0
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery Temperature"
    address: 1040
    register_type: "read"
    unit_of_measurement: °C
    device_class: temperature
    state_class: measurement    
    icon: mdi:thermometer
    value_type: U_WORD
    accuracy_decimals: 1
    # filters:
    # - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery State"
    address: 1041
    register_type: "read"
    icon: mdi:home-battery
    value_type: U_WORD

  - platform: modbus_controller
    name: "${devicename} Energy to User Today"
    address: 1044
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:transmission-tower-export
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1
    
  - platform: modbus_controller
    name: "${devicename} Energy to User Total"
    address: 1046
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:transmission-tower-export
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Energy to Grid Today"
    address: 1048
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:transmission-tower-import
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Energy to Grid Total"
    address: 1050
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:transmission-tower-import
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery Discharge Today"
    address: 1052
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:battery-arrow-down
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery Discharge Total"
    address: 1054
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:battery-arrow-down
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery Charge Today"
    address: 1056
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:battery-arrow-up-outline
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Battery Charge Total"
    address: 1058
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:battery-arrow-up-outline
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Local Load Today"
    address: 1060
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:home-import-outline
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1

  - platform: modbus_controller
    name: "${devicename} Local Load Total"
    address: 1062
    register_type: "read"
    unit_of_measurement: kWh
    state_class: total_increasing
    device_class: energy
    icon: mdi:home-import-outline
    value_type: U_DWORD
    accuracy_decimals: 1
    filters:
    - multiply: 0.1
  
number:
  - platform: modbus_controller
    name: "${devicename} Battery First Charge Rate"
    min_value: 0
    max_value: 100
    address: 1090
    register_type: "holding"

  - platform: modbus_controller
    name: "${devicename} Battery First Stop SoC"
    min_value: 0
    max_value: 100
    address: 1091
    register_type: "holding"
    

Don’t be put off by the length, most of it is just definitions of fields. The definitions I figured out from other places on the internet and this document. The tricky bit was getting all the writes to work.

The inverter appears to be somewhat transactional. There are a number of fields that have to be written as a group to work, typically any field relating to times. Using the drop down to set the Load/Grid/Battery first doesn’t work, you have to set it via the lambdas attached to the buttons. I didn’t write a ‘Grid First’ button as I never do that.

setAllTimes sets defaults behaviour for the Octopus tariff I used to be on, but Home Assistant controls that anyway so I hadn’t noticed it was out of date until now :)

I’ve been running this now for well over a year with few problems, it just quietly does its thing. All automations around when to actually charge are controlled by Home Assistant. I spit the data out to MQTT as well so that my OpenEVSE car charger can listen in and turn on if it is particularly sunny but otherwise I tend to just forget about this and it quietly chugs away in the loft.

I hope this helps as some sort of reference.