Innovenergy_trunk/ModbusTCP/kaco/modbus_tcp_client.py

264 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""
Modbus TCP client for KACO/Inesco SodistoreGrid battery storage system.
Reads system, battery, grid meter, and inverter registers.
Writes to inverter control registers to test server handling.
Register Map based on: inesco_Modbus_Register_Map_SodistoreGird.pdf
- Base address = 1
- Combines 32-bit low/high registers (low word first)
- Applies scaling factors (0.1, 0.01, etc.)
"""
from pymodbus.client.sync import ModbusTcpClient
import datetime
INVALID_16BIT = 0xFFFF
INVALID_32BIT = 0xFFFFFFFF
# --------------------------
# KACO/Inesco Register Map
# --------------------------
REGISTER_MAP = {
# ---- Read Only ----
# System Data (30001-30003)
30001: {"name": "Protocol Version", "type": "INT16", "scale": 0.1, "unit": "version"},
30002: {"name": "System Timestamp (Low)", "type": "UINT32", "scale": 1, "unit": "unix_ts"}, # 30002+30003
# Battery Data (31002-31010)
31002: {"name": "Avg Battery Voltage", "type": "INT16", "scale": 0.1, "unit": "V"},
31003: {"name": "Sum Battery Current", "type": "INT32", "scale": 0.1, "unit": "A"}, # 31003+31004 (Pos: Charge, Neg: Discharge)
31005: {"name": "Avg SOC", "type": "UINT16", "scale": 0.01, "unit": "%"},
31006: {"name": "Sum Battery Power", "type": "INT32", "scale": 0.1, "unit": "W"}, # 31006+31007 (Pos: Charge, Neg: Discharge)
31010: {"name": "Avg SOH", "type": "UINT16", "scale": 0.01, "unit": "%"},
# Grid Meter Data (33000-33011)
33000: {"name": "Grid Active Power", "type": "INT32", "scale": 0.1, "unit": "W"}, # 33000+33001
33002: {"name": "Grid Frequency", "type": "UINT16", "scale": 0.1, "unit": "Hz"},
33003: {"name": "Grid Voltage U1", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33004: {"name": "Grid Voltage U2", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33005: {"name": "Grid Voltage U3", "type": "UINT16", "scale": 0.1, "unit": "V"}, # Single register (PDF typo: listed as UINT32)
33006: {"name": "Grid Current I1", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33006+33007
33008: {"name": "Grid Current I2", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33008+33009
33010: {"name": "Grid Current I3", "type": "INT32", "scale": 0.1, "unit": "A"}, # 33010+33011
# Inverter Data - Read Only (34001-34005)
34001: {"name": "Inverter Power", "type": "UINT16", "scale": 1, "unit": "W"},
34002: {"name": "Max Charge Current (RO)", "type": "UINT16", "scale": 1, "unit": "A"},
34003: {"name": "Max Discharge Current (RO)", "type": "UINT16", "scale": 1, "unit": "A"},
34004: {"name": "Max Charge Voltage (RO)", "type": "UINT16", "scale": 1, "unit": "V"},
34005: {"name": "Min Discharge Voltage (RO)", "type": "UINT16", "scale": 1, "unit": "V"},
# ---- Write Registers (40001-40005) ----
40001: {"name": "Inverter Power Percentage", "type": "UINT16", "scale": 1, "unit": "%"},
40002: {"name": "Max Charge Current", "type": "UINT16", "scale": 1, "unit": "A"},
40003: {"name": "Max Discharge Current", "type": "UINT16", "scale": 1, "unit": "A"},
40004: {"name": "Max Charge Voltage", "type": "UINT16", "scale": 1, "unit": "V"},
40005: {"name": "Min Discharge Voltage", "type": "UINT16", "scale": 1, "unit": "V"},
}
# --------------------------
# Decode helpers
# --------------------------
def decode_signed_16bit(val):
if val is None:
return None
if val == 0x8000:
return None
if not 0 <= val <= 0xFFFF:
return None
return val - 0x10000 if val >= 0x8000 else val
def decode_unsigned_16bit(val):
if val is None:
return None
if val == INVALID_16BIT:
return None
if not 0 <= val <= 0xFFFF:
return None
return val
def decode_signed_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
if combined >= 0x80000000:
combined -= 0x100000000
return combined
def decode_unsigned_32bit(low, high):
if low is None or high is None:
return None
combined = (high << 16) | low
if combined == INVALID_32BIT:
return None
return combined
def scale(val, factor):
return round(val * factor, 2) if val is not None else "Missing/Invalid"
def timestamp_str_from_words(low, high):
ts = decode_unsigned_32bit(low, high)
if ts is None:
return "Missing/Invalid"
try:
dt = datetime.datetime.fromtimestamp(ts)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return f"Invalid timestamp ({ts})"
# --------------------------
# Modbus read helper (1-based -> pymodbus 0-based)
# --------------------------
def read_modbus_data(client, start_address, count):
try:
result = client.read_holding_registers(start_address - 1, count, unit=1)
if result.isError():
print(f"Error reading data from address {start_address}")
return None
return result.registers
except Exception as e:
print(f"Error reading from Modbus server: {e}")
return None
# --------------------------
# Generic block reader/printer
# --------------------------
def read_and_print_block(client, title, start_addr, end_addr):
"""
Reads [start_addr..end_addr] inclusive and prints values using REGISTER_MAP.
Handles INT32/UINT32 by consuming (low+high).
"""
count = end_addr - start_addr + 1
regs = read_modbus_data(client, start_addr, count)
if not regs:
print(f"\n--- {title} ---")
print("No data available.")
return
print(f"\n--- {title} ({start_addr}..{end_addr}) ---")
i = 0
while i < count:
addr = start_addr + i
info = REGISTER_MAP.get(addr)
# If not mapped, just skip printing (still consumes 1 word)
if info is None:
i += 1
continue
raw = regs[i]
dtype = info["type"]
factor = info.get("scale", 1)
unit = info.get("unit", "")
if dtype in ("INT32", "UINT32"):
if i + 1 >= count:
print(f"{info['name']} ({addr}+{addr+1}): Missing/Invalid (no high word)")
i += 1
continue
low = regs[i]
high = regs[i + 1]
if dtype == "INT32":
decoded = decode_signed_32bit(low, high)
else:
decoded = decode_unsigned_32bit(low, high)
# Special pretty-print for timestamp at 30002+30003
if addr == 30002 and dtype == "UINT32":
print(f"System Timestamp (30002+30003): {timestamp_str_from_words(low, high)}")
else:
scaled_val = scale(decoded, factor)
print(f"{info['name']} ({addr}+{addr+1}): {scaled_val} {unit}")
i += 2
continue
if dtype == "INT16":
decoded = decode_signed_16bit(raw)
elif dtype == "UINT16":
decoded = decode_unsigned_16bit(raw)
else:
decoded = raw
scaled_val = scale(decoded, factor)
print(f"{info['name']} ({addr}): {scaled_val} {unit}")
i += 1
# --------------------------
# Write helpers (1-based -> pymodbus 0-based)
# --------------------------
def to_u16(val):
return val & 0xFFFF
def write_u16(client, addr_1based, value):
try:
res = client.write_register(addr_1based - 1, to_u16(value), unit=1)
ok = not res.isError()
print(f"WRITE UINT16 {addr_1based} = {value} -> {'OK' if ok else 'FAIL'}")
return ok
except Exception as e:
print(f"WRITE UINT16 {addr_1based} exception: {e}")
return False
def write_test_kaco(client):
"""
Writes to KACO inverter control registers (40001-40005) to test server behavior.
Values are chosen as safe examples - adjust to your actual system limits.
"""
print("\n=== WRITE TEST (KACO INVERTER CONTROL REGISTERS) ===")
# 40001: Inverter Power Percentage (0-100%)
write_u16(client, 40001, 80) # Set to 80% of inverter capacity
# 40002: Max Charge Current (Amps)
write_u16(client, 40002, 50) # 50A max charge
# 40003: Max Discharge Current (Amps)
write_u16(client, 40003, 50) # 50A max discharge
# 40004: Max Charge Voltage (Volts)
write_u16(client, 40004, 580) # 580V max charge voltage
# 40005: Min Discharge Voltage (Volts)
write_u16(client, 40005, 420) # 420V min discharge voltage
def main():
client = ModbusTcpClient("localhost", port=502)
if not client.connect():
print("Failed to connect to Modbus server.")
print("Make sure modbus_tcp_server_kaco.py is running on port 502.")
return
try:
print("=" * 70)
print("KACO/Inesco SodistoreGrid - Modbus TCP Client Test")
print("=" * 70)
# ---- Read-only blocks ----
read_and_print_block(client, "System Data", 30001, 30003)
read_and_print_block(client, "Battery Data", 31002, 31010)
read_and_print_block(client, "Grid Meter Data", 33000, 33011)
read_and_print_block(client, "Inverter Data (Read-Only)", 34001, 34005)
# ---- Write tests ----
write_test_kaco(client)
# ---- Re-read write registers to verify ----
read_and_print_block(client, "Inverter Control (After Write)", 40001, 40005)
print("\n" + "=" * 70)
print("Test completed successfully!")
print("=" * 70)
finally:
client.close()
if __name__ == "__main__":
main()