#!/usr/bin/env python3 """ Modbus TCP client for reading system, battery, PV, grid, inverter and configuration registers, and writing all writable registers to test server handling. - Combines 32-bit low/high registers (low word first) - Applies scaling (multiply by scale factor) - Prints human-readable timestamp from 30002+30003 """ from pymodbus.client.sync import ModbusTcpClient import datetime INVALID_16BIT = 0xFFFF INVALID_32BIT = 0xFFFFFFFF # -------------------------- # Register Map (from PDF) # -------------------------- REGISTER_MAP = { # ---- Read Only ---- # System Data 30001: {"name": "Protocol Version", "type": "INT16", "scale": 0.1}, 30002: {"name": "System Timestamp", "type": "UINT32", "scale": 1}, # 30002+30003 30004: {"name": "Operating Priority", "type": "UINT16", "scale": 1}, # 0/1/2 # Battery Data 31000: {"name": "Battery Count", "type": "UINT16", "scale": 1}, 31001: {"name": "Battery Operating Status", "type": "UINT16", "scale": 1}, 31002: {"name": "Avg Battery Voltage", "type": "INT16", "scale": 0.1}, 31003: {"name": "Sum Battery Current", "type": "INT32", "scale": 0.1}, # 31003+31004 31005: {"name": "Avg SOC", "type": "UINT16", "scale": 0.01}, 31006: {"name": "Sum Battery Power", "type": "INT32", "scale": 0.1}, # 31006+31007 31008: {"name": "Min SOC", "type": "UINT16", "scale": 0.01}, 31009: {"name": "Max SOC", "type": "UINT16", "scale": 0.01}, 31010: {"name": "Avg SOH", "type": "UINT16", "scale": 0.01}, 31011: {"name": "Avg Battery Ambient Temperature", "type": "INT16", "scale": 0.01}, 31012: {"name": "Max Charge Current", "type": "UINT16", "scale": 0.1}, 31013: {"name": "Max Discharge Current", "type": "UINT16", "scale": 0.1}, 31014: {"name": "Max Charge Voltage", "type": "UINT16", "scale": 0.1}, # PV Data 32000: {"name": "Sum PV Power", "type": "UINT32", "scale": 0.1}, # 32000+32001 # Grid Data 33000: {"name": "Grid Power", "type": "INT32", "scale": 0.1}, # 33000+33001 33002: {"name": "Grid Frequency", "type": "UINT16", "scale": 0.1}, # Inverter Data (PDF seems to have a duplicated 34000; this is the consistent assumption) 34000: {"name": "System Operating Mode", "type": "UINT16", "scale": 1}, 34001: {"name": "Inverter Power", "type": "INT32", "scale": 0.1}, # 34001+34002 34003: {"name": "Inverter Device Type", "type": "UINT16", "scale": 1}, # Configuration Data (read) 35000: {"name": "Grid Setpoint (config/read)", "type": "INT32", "scale": 0.1}, # 35000+35001 35002: {"name": "Enable Grid Export (config/read)", "type": "UINT16", "scale": 1}, 35003: {"name": "Grid Export Percentage (config/read)", "type": "INT16", "scale": 1}, # ---- Write (RW) ---- 40001: {"name": "Write Operating Priority", "type": "UINT16", "scale": 1}, 40002: {"name": "Write Inverter Power %", "type": "UINT16", "scale": 1}, 41000: {"name": "Write Min SOC %", "type": "UINT16", "scale": 1}, 41001: {"name": "Write Max SOC %", "type": "UINT16", "scale": 1}, 41002: {"name": "Write Max Charge Current A", "type": "UINT16", "scale": 1}, 41003: {"name": "Write Max Discharge Current A", "type": "UINT16", "scale": 1}, 41004: {"name": "Write Max Charge Voltage V", "type": "UINT16", "scale": 1}, # PV write exists in map as "PV 43000" but no details (not available now) 43000: {"name": "PV Write Placeholder", "type": "UINT16", "scale": 1}, # Grid Write 44000: {"name": "Write Grid Power Setpoint W", "type": "INT32", "scale": 1}, # 44000+44001 44002: {"name": "Write Enable Grid Export", "type": "UINT16", "scale": 1}, } # -------------------------- # 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) 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: print(f"{info['name']} ({addr}+{addr+1}): {scale(decoded, factor)}") i += 2 continue if dtype == "INT16": decoded = decode_signed_16bit(raw) elif dtype == "UINT16": decoded = decode_unsigned_16bit(raw) else: decoded = raw print(f"{info['name']} ({addr}): {scale(decoded, factor)}") i += 1 # -------------------------- # Write helpers (1-based -> pymodbus 0-based) # -------------------------- def to_u16(val): return val & 0xFFFF def split_int32(value_signed): """ Returns (low_word, high_word) for signed int32, low word first. """ v = int(value_signed) if v < 0: v = (1 << 32) + v low = v & 0xFFFF high = (v >> 16) & 0xFFFF return low, high 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_int32(client, addr_low_1based, value_signed): low, high = split_int32(value_signed) try: res = client.write_registers(addr_low_1based - 1, [low, high], unit=1) ok = not res.isError() print(f"WRITE INT32 {addr_low_1based}+{addr_low_1based+1} = {value_signed} -> {'OK' if ok else 'FAIL'} (low={low}, high={high})") return ok except Exception as e: print(f"WRITE INT32 {addr_low_1based} exception: {e}") return False def write_test_all(client): """ Writes all RW registers from the PDF to test server behavior. (Values chosen to be safe-ish examples; adjust to your device limits.) """ print("\n=== WRITE TEST (ALL RW REGISTERS) ===") # Inverter (write) write_u16(client, 40001, 1) # Operating Priority example write_u16(client, 40002, 30) # Inverter Power % example (0..100) # Battery (write) write_u16(client, 41000, 20) # Min SOC % write_u16(client, 41001, 90) # Max SOC % write_u16(client, 41002, 50) # Max Charge Current A write_u16(client, 41003, 50) # Max Discharge Current A write_u16(client, 41004, 520) # Max Charge Voltage V # PV (write) - unclear/greyed out in PDF, still try if server supports it write_u16(client, 43000, 0) # Grid (write) write_int32(client, 44000, 10) # Grid Power Setpoint W (positive/negative allowed) write_u16(client, 44002, 1) # Enable Grid Export (0/1) write_int32(client, 44003, 25) # Grid Export Percentage % (-100..100) def main(): client = ModbusTcpClient("localhost", port=502) if not client.connect(): print("Failed to connect to Modbus server.") return try: # ---- Read-only blocks ---- read_and_print_block(client, "System Data", 30001, 30004) read_and_print_block(client, "Battery Data", 31000, 31014) read_and_print_block(client, "PV Data", 32000, 32001) read_and_print_block(client, "Grid Data", 33000, 33002) # Inverter Data block (see note in header) read_and_print_block(client, "Inverter Data", 34000, 34003) # Configuration Data (read) # Note: Grid Export Percentage is INT32 starting at 35003 -> needs 35004 as high word. # If your server doesn't provide 35004, you'll see Missing/Invalid. read_and_print_block(client, "Configuration Data", 35000, 35004) # ---- Write tests ---- write_test_all(client) # Re-read key RW registers after write read_and_print_block(client, "Post-write Inverter RW", 40001, 40002) read_and_print_block(client, "Post-write Battery RW", 41000, 41004) read_and_print_block(client, "Post-write Grid RW", 44000, 44004) finally: client.close() if __name__ == "__main__": main()