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
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_datafunction 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