Skip to content

Sample code for your Growatt SPH Inverter Implementation #1

@ned-kelly

Description

@ned-kelly

Hi, I'm looking to create my own middleware between my battery and my Growatt SPH Inverter (which only supports Growatt brand batteries) and I stumbled across your project and github from your posts on the secondlifestorage forums.

Would it be possible for you to share the sources of how you post data to the inverter when configured to emulate a growatt battery?

I've gone through the "Growatt_BMS_RS485_ Protocol _1xSxxP_ESS_Rev2.01" manual as best I can, but something still does not work when I try and 'emulate' a battery and i can't quite pinpoint it because the english/documentation in the manual is not very clear.

you can see below in the generate_bms_data function the registers i'm sending back via modbusRTU when the inverter polls via rs485, but i have a feeling i've still got a few of the data formatted incorrectly and/or i'm still missing a few registers...

I appreciate any guidance you can point me in, or even more your own working code so I can study how you've emulated the growatt protocol implementation.

Thanks

def generate_bms_data():
    """Generates dynamic BMS data, ensuring up-to-date system values."""
    datetime_low, datetime_high = set_modbus_datetime()  # Fetch fresh system time

    return {
        0x0010: 2500,  # Gauge IC current (10mA units) - currently 25A
        0x0011: datetime_low,  # Date & Time (low)
        0x0012: datetime_high,  # Date & Time (high)
        0x0013: hex(get_status_register(status_mode="standby", force_charge_enabled=False)),  # System Status 
        0x0014: hex(get_error_register()),  # Error Register 1
        0x0015: hex(get_soc_register(70)),  # SOC % (e.g., 70%)
        0x0016: 5271,  # Battery Voltage (10mV units, 52.71V)
        0x0017: hex(get_current_register(0)),  # Battery Current (10mA units)
        0x0018: 32,  # Battery Temperature (tenths of °C) - if error try in tenths of degree
        0x0019: 5300,  # Max Charge/Discharge Current (10mA units)

        0x001A: 37253,  # Remaining Capacity (10mAh units)
        0x001B: 40000,  # Full Charge Capacity (10mAh units)
        0x001C: 0,  # YW/FW (firmware-related?)
        0x001D: 80,  # Delta Cell Voltage (V)
        0x001E: 10,  # Cycle Count
        0x001F: 0,  # Reserved for Master Box

        0x0020: hex(get_soh_register(99)),  # State of Health (SOH) %
        0x0021: 5200,  # CV Voltage (10mV units)
        0x0022: 0,  # Warning Code (Bitfield)

        0x0023: 5500,  # Max Discharge Current (10mA units)
        0x0024: 0,  # Extended Error
        0x0025: 3517,  # Maximum Cell Voltage (1mV units)
        0x0026: 3389,  # Minimum Cell Voltage (1mV units)

        0x0027: 3600,  # Max Cell Voltage Number
        0x0028: 2900,  # Min Cell Voltage Number
        0x0029: 15,  # Cell Series (e.g., 16S battery)

        0x000C: 0,  # Request Force Charge Flag

        # Cell Voltages - currently all hard coded values
        0x0071: 3490, 0x0072: 3490, 0x0073: 3490, 0x0074: 3490, 0x0075: 3490,
        0x0076: 3490, 0x0077: 3490, 0x0078: 3490, 0x0079: 3490, 0x007A: 3490,
        0x007B: 3490, 0x007C: 3490, 0x007D: 3490, 0x007E: 3490, 0x007F: 3490,
        0x0080: 3490, 0x0081: 3500
    }

def set_modbus_datetime():
    """Encodes the current system date and time into Modbus registers 0x0011 and 0x0012."""
    now = time.localtime()  # Get the current local time
    
    second = now.tm_sec & 0x3F       # 6 bits
    minute = now.tm_min & 0x3F       # 6 bits
    hour = now.tm_hour & 0x1F        # 5 bits
    day = now.tm_mday & 0x1F         # 5 bits
    month = now.tm_mon & 0xF         # 4 bits
    year = (now.tm_year - 2000) & 0x3F  # 6 bits (offset from 2000)

    logging.debug(f"Encoding DateTime -> Year: {year + 2000}, Month: {month}, Day: {day}, "
                  f"Hour: {hour}, Minute: {minute}, Second: {second}")

    # Construct the 32-bit datetime value
    datetime_value = (
        (second) |
        (minute << 6) |
        (hour << 12) |
        (day << 17) |
        (month << 22) |
        (year << 26)
    )

    # Split into two 16-bit registers
    datetime_low = datetime_value & 0xFFFF
    datetime_high = (datetime_value >> 16) & 0xFFFF

    logging.debug(f"🕒 Updated DateTime: {now.tm_year}-{now.tm_mon:02}-{now.tm_mday:02} "
                  f"{now.tm_hour:02}:{now.tm_min:02}:{now.tm_sec:02} -> "
                  f"Registers (LOW first!): {hex(datetime_low)}, {hex(datetime_high)}")

    return datetime_low, datetime_high

def get_status_register(status_mode="standby", force_charge_enabled=False):
    """Generates the status register value for 0x0013 with MSB set to 0x00.

    Args:
        status_mode (str): One of ["soft_starting", "standby", "charging", "discharging"].
        force_charge_enabled (bool): Set to True to enable force charge.

    Returns:
        int: 16-bit Modbus register value (upper byte is 0x00).
    """

    # Mapping status_mode to corresponding bit values (Bits 0-1)
    status_modes = {
        "soft_starting": 0b00,
        "standby": 0b01,
        "charging": 0b10,
        "discharging": 0b11,
    }
    
    status_bits = status_modes.get(status_mode, 0b01)  # Default: standby

    # Constructing only the **lower 8-bit** status register
    status_register_low = (
        (status_bits & 0b11)                |  # Bits 0-1: System status
        (0b0 << 2)                          |  # Bit 2: Error flag (0 = no error)
        (0b1 << 3)                          |  # Bit 3: Cell balance active
        (0b0 << 4)                          |  # Bit 4: Sleep status (0 = disabled)
        (0b1 << 5)                          |  # Bit 5: Discharge allowed
        (0b1 << 6)                          |  # Bit 6: Charge allowed
        (0b0 << 7)                          |  # Bit 7: Battery terminals connected
        (0b00 << 8)                         |  # Bits 8-9: Master box mode (00 = single)
        (0b00 << 10)                        |  # Bits 10-11: SP status (Unknown, set to 00)
        ((0b1 if force_charge_enabled else 0b0) << 12)  # Bit 12: Force charge
    )

    # Ensure first byte is **always** `0x00` → Shift left by 8 and OR with `status_register_low`
    full_status_register = (0x00 << 8) | (status_register_low & 0xFF)

    return full_status_register

def get_soc_register(soc: int) -> int:
    """
    Encodes the State of Charge (SOC) value into a 16-bit Modbus register.
    
    Args:
        soc (int): The SOC percentage (0-100%).
    
    Returns:
        int: The 16-bit Modbus register value with first byte as 0x00 and second byte as SOC.
    """
    if not (0 <= soc <= 100):
        raise ValueError("SOC must be between 0 and 100%")

    return (0x00 << 8) | (soc & 0xFF)  # First byte = 0x00, Second byte = SOC

def get_current_register(current_amps: float = 0.0) -> int:


    """
    Encodes the battery current into a 16-bit Modbus register (0x0017).

    Args:
        current_amps (float): Current in Amps (A). Positive = charging, Negative = discharging.
    
    Returns:
        int: The 16-bit Modbus register value.

    EXAMPLES:
        print(hex(get_current_register(25.0)))   # Charging at 25A
        # Output: 0x09c4  (2500 in 10mA units)
        print(hex(get_current_register(-10.5)))  # Discharging at -10.5A
        # Output: 0xd79b  (2's complement encoding)
        print(hex(get_current_register(0)))      # Idle (no current)
        # Output: 0x0000
    """
    # Convert to 10mA units
    current_value = int(current_amps * 100)

    # Handle negative current (discharging) using 2's complement
    if current_value < 0:
        current_value = (abs(current_value) ^ 0xFFFF) + 1  # 2's complement for 16-bit

    return current_value & 0xFFFF  # Ensure 16-bit register

def get_soh_register(soh_percent: int, soh_flag: bool = True) -> int:
    """
    Encodes the State of Health (SOH) into a 16-bit Modbus register (0x0020).

    Args:
        soh_percent (int): SOH percentage (0-100%).
        soh_flag (bool): SOH Flag (True = Set, False = Not Set).

    Returns:
        int: The 16-bit Modbus register value.
    """
    # Ensure SOH is within valid range (0-100%)
    soh_value = max(0, min(soh_percent, 100))

    # Encode SOH Counter (Bits 0-6) and SOH Flag (Bit 7)
    soh_encoded = (soh_value & 0x7F) | ((1 if soh_flag else 0) << 7)

    return soh_encoded & 0xFFFF  # Ensure 16-bit register

def get_error_register(
    ocd=False,  # Over Current Discharge
    scd=False,  # Short Circuit Discharge
    ov=False,   # Over Voltage
    uv=False,   # Under Voltage
    otd=False,  # Over Temp Discharge
    otc=False,  # Over Temp Charge
    utd=False,  # Under Temp Discharge
    utc=False,  # Under Temp Charge
    soft_start_fail=False,
    permanent_fault=False,
    delta_v_fail=False,
    occ=False,  # Over Current Charge
    ot_mos=False,  # MOS Over Temp
    ot_env=False,  # Environment Over Temp
    ut_env=False,  # Environment Under Temp
):
    """Generates the error register value for 0x0014 with selected error flags.

    Args:
        Each argument is a boolean indicating whether the respective error should be active (True) or not (False).

    Returns:
        int: 16-bit Modbus register value representing the error status.
    """
    
    error_register = (
        (0b1 if ocd else 0b0) << 0  |  # Bit 0: OCD (Over Current Discharge)
        (0b1 if scd else 0b0) << 1  |  # Bit 1: SCD (Short Circuit Discharge)
        (0b1 if ov else 0b0) << 2   |  # Bit 2: OV (Over Voltage)
        (0b1 if uv else 0b0) << 3   |  # Bit 3: UV (Under Voltage)
        (0b1 if otd else 0b0) << 4  |  # Bit 4: OTD (Over Temp Discharge)
        (0b1 if otc else 0b0) << 5  |  # Bit 5: OTC (Over Temp Charge)
        (0b1 if utd else 0b0) << 6  |  # Bit 6: UTD (Under Temp Discharge)
        (0b1 if utc else 0b0) << 7  |  # Bit 7: UTC (Under Temp Charge)
        (0b1 if soft_start_fail else 0b0) << 8  |  # Bit 8: Soft Start Fail
        (0b1 if permanent_fault else 0b0) << 9  |  # Bit 9: Permanent Fault
        (0b1 if delta_v_fail else 0b0) << 10 |  # Bit 10: Delta V Fail
        (0b1 if occ else 0b0) << 11 |  # Bit 11: OCC (Over Current Charge)
        (0b1 if ot_mos else 0b0) << 12 |  # Bit 12: OT (MOS Over Temp)
        (0b1 if ot_env else 0b0) << 13 |  # Bit 13: OT (Environment Over Temp)
        (0b1 if ut_env else 0b0) << 14   # Bit 14: UT (Environment Under Temp)
    )

    return error_register

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions