303 lines
11 KiB
Python
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()
|