Innovenergy_trunk/ModbusTCP/modbus_tcp_client.py

303 lines
11 KiB
Python

#!/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()