sodistore home modbust tcp service source code
This commit is contained in:
parent
bee5d8e1e7
commit
bb1efaf0e9
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=ModbusTcp Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/home/ie-entwicklung/salimax/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
Restart=always
|
||||||
|
User=ie-entwicklung
|
||||||
|
Group=nogroup
|
||||||
|
StandardOutput=append:/var/log/ModbusTCPService.log
|
||||||
|
StandardError=inherit
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=ModbusTcp Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/home/inesco/SodiStoreHome/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
Restart=always
|
||||||
|
User=inesco
|
||||||
|
Group=nogroup
|
||||||
|
StandardOutput=append:/var/log/ModbusTCPService.log
|
||||||
|
StandardError=inherit
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Sodistore Home ---
|
||||||
|
REMOTE_USER="inesco"
|
||||||
|
REMOTE_HOST="10.2.5.33"
|
||||||
|
HOME_PATH="/home/inesco/SodiStoreHome"
|
||||||
|
REMOTE_BASE="/home/inesco/SodiStoreHome/ModbusTCP"
|
||||||
|
# --- Sodistore Max ---
|
||||||
|
# REMOTE_USER="ie-entwicklung"
|
||||||
|
# REMOTE_HOST="10.2.5.25"
|
||||||
|
# HOME_PATH="/home/ie-entwicklung/salimax"
|
||||||
|
# REMOTE_BASE="/home/ie-entwicklung/salimax/ModbusTCP"
|
||||||
|
|
||||||
|
REMOTE_DIST="$REMOTE_BASE/dist"
|
||||||
|
SERVICE_NAME="ModbusTCP.service"
|
||||||
|
PYTHON_FILE="modbus_tcp_server.py"
|
||||||
|
SERVICE_FILE="ModbusTCP.service"
|
||||||
|
BINARY_NAME="modbus_tcp_server"
|
||||||
|
|
||||||
|
# SUDO_PASSWORD="Salimax4x25"
|
||||||
|
|
||||||
|
# echo "==> Preparing Python virtual environment..."
|
||||||
|
# cd "$HOME_PATH"
|
||||||
|
# python3 -m venv venv
|
||||||
|
# source venv/bin/activate
|
||||||
|
# pip install watchdog
|
||||||
|
# pip install pymodbus==2.5.3
|
||||||
|
# pip install pyinstaller
|
||||||
|
# deactivate
|
||||||
|
|
||||||
|
# echo "==> Copying source file to remote..."
|
||||||
|
scp "$PYTHON_FILE" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_BASE/"
|
||||||
|
# scp "$SERVICE_FILE" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_BASE/"
|
||||||
|
|
||||||
|
echo "==> Building Python binary on remote..."
|
||||||
|
ssh "$REMOTE_USER@$REMOTE_HOST" "
|
||||||
|
cd $HOME_PATH && \
|
||||||
|
source venv/bin/activate && \
|
||||||
|
cd ModbusTCP && \
|
||||||
|
pyinstaller --onefile $PYTHON_FILE
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "==> Cleaning up temporary build artifacts on remote..."
|
||||||
|
ssh "$REMOTE_USER@$REMOTE_HOST" "
|
||||||
|
cd $REMOTE_BASE && \
|
||||||
|
rm -f $PYTHON_FILE && \
|
||||||
|
rm -rf build __pycache__ *.spec
|
||||||
|
# "
|
||||||
|
echo "==> Setting up systemd service on remote..."
|
||||||
|
ssh "$REMOTE_USER@$REMOTE_HOST" "
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S cp '$REMOTE_BASE/ModbusTCP.service' /etc/systemd/system/"
|
||||||
|
|
||||||
|
echo "==> Restarting systemd service on remote..."
|
||||||
|
ssh "$REMOTE_USER@$REMOTE_HOST" "
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S setcap 'cap_net_bind_service=+ep' '$REMOTE_BASE/dist/modbus_tcp_server' && \
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S systemctl daemon-reload && \
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S systemctl enable $SERVICE_NAME && \
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S systemctl stop $SERVICE_NAME && \
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S systemctl start $SERVICE_NAME && \
|
||||||
|
echo '$SUDO_PASSWORD' | sudo -S systemctl status $SERVICE_NAME --no-pager -l
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Deployment to $REMOTE_HOST completed successfully!"
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,263 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"30001": 100,
|
||||||
|
"30002": 36343,
|
||||||
|
"30003": 26755,
|
||||||
|
|
||||||
|
"31002": 5200,
|
||||||
|
"31003": 125,
|
||||||
|
"31004": 0,
|
||||||
|
"31005": 8542,
|
||||||
|
"31006": 6500,
|
||||||
|
"31007": 0,
|
||||||
|
"31010": 9850,
|
||||||
|
|
||||||
|
"33000": 32000,
|
||||||
|
"33001": 0,
|
||||||
|
"33002": 500,
|
||||||
|
"33003": 2300,
|
||||||
|
"33004": 2310,
|
||||||
|
"33005": 2290,
|
||||||
|
"33006": 140,
|
||||||
|
"33007": 0,
|
||||||
|
"33008": 138,
|
||||||
|
"33009": 0,
|
||||||
|
"33010": 142,
|
||||||
|
"33011": 0,
|
||||||
|
|
||||||
|
"34001": 3000,
|
||||||
|
"34002": 100,
|
||||||
|
"34003": 100,
|
||||||
|
"34004": 580,
|
||||||
|
"34005": 420
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Modbus TCP Server for KACO/Inesco SodistoreGrid System
|
||||||
|
Adapted for the Inesco Modbus Register Map (Base address = 1)
|
||||||
|
|
||||||
|
Register Map Overview:
|
||||||
|
- System Data (Read): 30001-30003 (Protocol Version, System Timestamp)
|
||||||
|
- Battery Data (Read): 31002-31010 (Voltage, Current, SOC, Power, SOH)
|
||||||
|
- Grid Meter (Read): 33000-33009 (Active Power, Frequency, Voltages, Currents)
|
||||||
|
- Inverter Data (Read): 34001-34005 (Power, Max Charge/Discharge Current, Voltages)
|
||||||
|
- Inverter Control (Write): 40001-40005 (Power Percentage, Max Charge/Discharge Current, Voltages)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from pymodbus.server.sync import ModbusTcpServer
|
||||||
|
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
|
||||||
|
from pymodbus.datastore import ModbusSequentialDataBlock
|
||||||
|
from threading import Lock
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
INVALID_16BIT_VALUE = 0xFFFF
|
||||||
|
|
||||||
|
class ModbusDataStore(ModbusSequentialDataBlock):
|
||||||
|
def __init__(self, json_file, config_json):
|
||||||
|
self.json_file = json_file
|
||||||
|
self.config_json = config_json
|
||||||
|
self.start_address = 30001
|
||||||
|
self.end_address = 40005 # Updated for KACO register map (highest write register)
|
||||||
|
self.values = [INVALID_16BIT_VALUE] * (self.end_address - self.start_address + 1)
|
||||||
|
self.lock = Lock()
|
||||||
|
self.load_and_set_values()
|
||||||
|
super().__init__(self.start_address, self.values)
|
||||||
|
|
||||||
|
def load_data_from_json(self):
|
||||||
|
logging.debug(f"Loading data from JSON file: {self.json_file}")
|
||||||
|
try:
|
||||||
|
with open(self.json_file, 'r') as file:
|
||||||
|
data = json.load(file)
|
||||||
|
logging.debug("Data loaded successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error loading JSON file: {e}")
|
||||||
|
raise e
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create_values(self, data_dict):
|
||||||
|
size = self.end_address - self.start_address + 1
|
||||||
|
values = [INVALID_16BIT_VALUE] * size # full range
|
||||||
|
|
||||||
|
for key, val in data_dict.items():
|
||||||
|
addr = int(key)
|
||||||
|
if self.start_address <= addr <= self.end_address:
|
||||||
|
index = addr - self.start_address
|
||||||
|
values[index] = val
|
||||||
|
else:
|
||||||
|
logging.warning(f"Address {addr} outside range {self.start_address}-{self.end_address}, skipping.")
|
||||||
|
|
||||||
|
logging.info(f"Loaded {len(values)} registers from JSON (full range).")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_set_values(self):
|
||||||
|
with self.lock:
|
||||||
|
self.data_dict = self.load_data_from_json()
|
||||||
|
self.values = self.create_values(self.data_dict)
|
||||||
|
|
||||||
|
def getValues(self, address, count=1):
|
||||||
|
with self.lock:
|
||||||
|
values = super().getValues(address, count)
|
||||||
|
logging.debug(f"Reading values from address {address}, count {count}: {values}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
def reload_json(self):
|
||||||
|
logging.info(f"Reloading JSON data from file: {self.json_file}")
|
||||||
|
try:
|
||||||
|
self.load_and_set_values()
|
||||||
|
self.update_data_store()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to reload data: {e}")
|
||||||
|
|
||||||
|
def update_data_store(self):
|
||||||
|
with self.lock:
|
||||||
|
for index, value in enumerate(self.values):
|
||||||
|
super().setValues(self.start_address + index, [value])
|
||||||
|
|
||||||
|
def setValues(self, address, values):
|
||||||
|
"""
|
||||||
|
Handle Modbus writes and update only relevant entries in config.json
|
||||||
|
|
||||||
|
KACO/Inesco Write Registers (Base address = 1):
|
||||||
|
40001: Inverter Power Percentage (UINT16, 1[%]) - Percentage of inverter capacity [0,100]
|
||||||
|
40002: Max Charge Current (UINT16, 1[A]) - Consider all batteries' capacity
|
||||||
|
40003: Max Discharge Current (UINT16, 1[A]) - Consider all batteries' capacity
|
||||||
|
40004: Max Charge Voltage (UINT16, 1[V]) - Consider all batteries' capacity
|
||||||
|
40005: Min Discharge Voltage (UINT16, 1[V]) - Consider all batteries' capacity
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
super().setValues(address, values)
|
||||||
|
logging.debug(f"Received Modbus write: address {address}, values {values}")
|
||||||
|
|
||||||
|
# KACO/Inesco Register Map - Write Registers
|
||||||
|
WRITE_REGISTERS = {
|
||||||
|
40001: {"key": "InverterPowerPercentage", "type": "UINT16", "scale": 1, "unit": "%"},
|
||||||
|
40002: {"key": "MaxChargeCurrent", "type": "UINT16", "scale": 1, "unit": "A"},
|
||||||
|
40003: {"key": "MaxDischargeCurrent", "type": "UINT16", "scale": 1, "unit": "A"},
|
||||||
|
40004: {"key": "MaxChargeVoltage", "type": "UINT16", "scale": 1, "unit": "V"},
|
||||||
|
40005: {"key": "MinDischargeVoltage", "type": "UINT16", "scale": 1, "unit": "V"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def combine_words(low, high, signed=False):
|
||||||
|
val = (high << 16) | low
|
||||||
|
if signed and val & 0x80000000:
|
||||||
|
val -= 0x100000000
|
||||||
|
return val
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(values):
|
||||||
|
reg_address = address + i
|
||||||
|
reg_info = WRITE_REGISTERS.get(reg_address)
|
||||||
|
|
||||||
|
if reg_info:
|
||||||
|
key = reg_info["key"]
|
||||||
|
dtype = reg_info["type"]
|
||||||
|
scale = reg_info.get("scale", 1)
|
||||||
|
|
||||||
|
if dtype in ("UINT16", "INT16"):
|
||||||
|
raw = values[i]
|
||||||
|
val = raw * scale
|
||||||
|
if dtype == "INT16" and raw & 0x8000:
|
||||||
|
val = raw - 0x10000
|
||||||
|
updates[key] = val
|
||||||
|
logging.info(f"Register {reg_address} ({key}): {val} {reg_info.get('unit', '')}")
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
elif dtype == "INT32" and i + 1 < len(values):
|
||||||
|
low = values[i]
|
||||||
|
high = values[i + 1]
|
||||||
|
val = combine_words(low, high, signed=True) * scale
|
||||||
|
updates[key] = val
|
||||||
|
logging.info(f"Register {reg_address} ({key}): {val} {reg_info.get('unit', '')}")
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
logging.warning(f"Incomplete write for {reg_address}")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.config_json):
|
||||||
|
with open(self.config_json, "r") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
else:
|
||||||
|
config_data = {}
|
||||||
|
|
||||||
|
# Update only the relevant keys, keep rest unchanged
|
||||||
|
for k, v in updates.items():
|
||||||
|
config_data[k] = v
|
||||||
|
|
||||||
|
with open(self.config_json, "w") as f:
|
||||||
|
json.dump(config_data, f, indent=4)
|
||||||
|
|
||||||
|
logging.info(f"Updated {self.config_json} keys: {list(updates.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to update {self.config_json}: {e}")
|
||||||
|
|
||||||
|
class JSONFileChangeHandler(FileSystemEventHandler):
|
||||||
|
def __init__(self, store, json_file):
|
||||||
|
self.store = store
|
||||||
|
self.json_file = os.path.abspath(json_file)
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
if os.path.abspath(event.src_path) == self.json_file:
|
||||||
|
logging.info(f"File {self.json_file} changed, reloading...")
|
||||||
|
self.store.reload_json()
|
||||||
|
|
||||||
|
def create_server(data_json, config_json):
|
||||||
|
data_json = os.path.abspath(data_json)
|
||||||
|
config_json = os.path.abspath(config_json)
|
||||||
|
|
||||||
|
logging.info(f"Starting KACO/Inesco Modbus TCP Server")
|
||||||
|
logging.info(f"Data JSON (read-only telemetry): {data_json}")
|
||||||
|
logging.info(f"Config JSON (writable parameters): {config_json}")
|
||||||
|
|
||||||
|
store = ModbusDataStore(data_json, config_json)
|
||||||
|
|
||||||
|
slave = ModbusSlaveContext(
|
||||||
|
hr=store,
|
||||||
|
ir=store,
|
||||||
|
)
|
||||||
|
context = ModbusServerContext(slaves=slave, single=True)
|
||||||
|
|
||||||
|
# file change observer for telemetry JSON
|
||||||
|
event_handler = JSONFileChangeHandler(store, data_json)
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(event_handler, path=os.path.dirname(data_json), recursive=False)
|
||||||
|
observer.start()
|
||||||
|
|
||||||
|
server = ModbusTcpServer(context, address=("0.0.0.0", 502))
|
||||||
|
logging.info("Modbus TCP Server running on 0.0.0.0:502")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error running the Modbus server: {e}")
|
||||||
|
finally:
|
||||||
|
observer.stop()
|
||||||
|
observer.join()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# KACO/Inesco System Paths
|
||||||
|
# Update these paths according to your deployment
|
||||||
|
|
||||||
|
# Option 1: Absolute paths (uncomment to use)
|
||||||
|
# json_path = "/home/ie-entwicklung/kaco/ModbusTCP/modbus_tcp_data.json"
|
||||||
|
# config_path = "/home/ie-entwicklung/kaco/config.json"
|
||||||
|
|
||||||
|
# Option 2: Relative to script/binary location (recommended for portability)
|
||||||
|
# Works for both Python script and PyInstaller binary
|
||||||
|
# Script location: /path/to/product/ModbusTCP/modbus_tcp_server.py
|
||||||
|
# Binary location: /path/to/product/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
# JSON location: /path/to/product/ModbusTCP/modbus_tcp_data.json
|
||||||
|
# Config location: /path/to/product/config.json
|
||||||
|
|
||||||
|
# Detect if running as PyInstaller binary or Python script
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as PyInstaller binary: use sys.executable path
|
||||||
|
# Binary is in: /path/to/product/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
# Need to go up one level to get to ModbusTCP folder
|
||||||
|
current_file_dir = Path(sys.executable).resolve().parent.parent
|
||||||
|
else:
|
||||||
|
# Running as Python script: use __file__ path
|
||||||
|
current_file_dir = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
json_path = current_file_dir / "modbus_tcp_data.json"
|
||||||
|
config_path = current_file_dir.parent / "config.json"
|
||||||
|
|
||||||
|
# Convert Path objects to strings
|
||||||
|
json_path = str(json_path)
|
||||||
|
config_path = str(config_path)
|
||||||
|
|
||||||
|
if not os.path.isfile(json_path):
|
||||||
|
logging.error(f"Modbus data JSON file not found: {json_path}. Server cannot start.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.isfile(config_path):
|
||||||
|
logging.error(f"Config JSON file not found: {config_path}. Server cannot start.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
create_server(json_path, config_path)
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"30001": 2020,
|
||||||
|
"30002": 36343,
|
||||||
|
"30003": 26755,
|
||||||
|
"30004": 6,
|
||||||
|
"31000": 2,
|
||||||
|
"31001": 64516,
|
||||||
|
"31002": 65535,
|
||||||
|
"31003": 160,
|
||||||
|
"31004": 0,
|
||||||
|
"31005": 3220,
|
||||||
|
"31006": 0,
|
||||||
|
"31007": 170,
|
||||||
|
"31008": 0,
|
||||||
|
"31009": 2810,
|
||||||
|
"31010": 0,
|
||||||
|
"31011": 42764,
|
||||||
|
"31012": 1,
|
||||||
|
"31013": 42764,
|
||||||
|
"31014": 1,
|
||||||
|
"31015": 570,
|
||||||
|
"31016": 65516,
|
||||||
|
"31017": 65535,
|
||||||
|
"31018": 9600,
|
||||||
|
"31019": 5000,
|
||||||
|
"31021": 0
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from pymodbus.server.sync import ModbusTcpServer
|
||||||
|
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext
|
||||||
|
from pymodbus.datastore import ModbusSequentialDataBlock
|
||||||
|
from threading import Lock
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
INVALID_16BIT_VALUE = 0xFFFF
|
||||||
|
|
||||||
|
class ModbusDataStore(ModbusSequentialDataBlock):
|
||||||
|
def __init__(self, json_file,config_json):
|
||||||
|
self.json_file = json_file
|
||||||
|
self.config_json = config_json
|
||||||
|
self.start_address = 30001
|
||||||
|
self.end_address = 44004 # Need to update here when there are more registers!!!!
|
||||||
|
self.values = [INVALID_16BIT_VALUE] * (self.end_address - self.start_address + 1)
|
||||||
|
self.lock = Lock()
|
||||||
|
self.load_and_set_values()
|
||||||
|
super().__init__(self.start_address, self.values)
|
||||||
|
|
||||||
|
def load_data_from_json(self):
|
||||||
|
logging.debug(f"Loading data from JSON file: {self.json_file}")
|
||||||
|
try:
|
||||||
|
with open(self.json_file, 'r') as file:
|
||||||
|
data = json.load(file)
|
||||||
|
logging.debug("Data loaded successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error loading JSON file: {e}")
|
||||||
|
raise e
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create_values(self, data_dict):
|
||||||
|
size = self.end_address - self.start_address + 1
|
||||||
|
values = [INVALID_16BIT_VALUE] * size # full range
|
||||||
|
|
||||||
|
for key, val in data_dict.items():
|
||||||
|
addr = int(key)
|
||||||
|
if self.start_address <= addr <= self.end_address:
|
||||||
|
index = addr - self.start_address
|
||||||
|
values[index] = val
|
||||||
|
else:
|
||||||
|
logging.warning(f"Address {addr} outside range {self.start_address}-{self.end_address}, skipping.")
|
||||||
|
|
||||||
|
logging.info(f"Loaded {len(values)} registers from JSON (full range).")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_set_values(self):
|
||||||
|
with self.lock:
|
||||||
|
self.data_dict = self.load_data_from_json()
|
||||||
|
self.values = self.create_values(self.data_dict)
|
||||||
|
|
||||||
|
def getValues(self, address, count=1):
|
||||||
|
with self.lock:
|
||||||
|
values = super().getValues(address, count)
|
||||||
|
logging.debug(f"Reading values from address {address}, count {count}: {values}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
def reload_json(self):
|
||||||
|
logging.info(f"Reloading JSON data from file: {self.json_file}")
|
||||||
|
try:
|
||||||
|
self.load_and_set_values()
|
||||||
|
self.update_data_store()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to reload data: {e}")
|
||||||
|
|
||||||
|
def update_data_store(self):
|
||||||
|
with self.lock:
|
||||||
|
for index, value in enumerate(self.values):
|
||||||
|
super().setValues(self.start_address + index, [value])
|
||||||
|
|
||||||
|
def setValues(self, address, values):
|
||||||
|
"""Handle Modbus writes and update only relevant entries in config.json"""
|
||||||
|
with self.lock:
|
||||||
|
super().setValues(address, values)
|
||||||
|
logging.debug(f"Received Modbus write: address {address}, values {values}")
|
||||||
|
|
||||||
|
WRITE_REGISTERS = {
|
||||||
|
# Correct 1-based addresses matching SodistoreHome PDF
|
||||||
|
40001: {"key": "OperatingPriority", "type": "UINT16", "scale": 1},
|
||||||
|
40002: {"key": "InverterPower", "type": "UINT16", "scale": 1},
|
||||||
|
41000: {"key": "MinSoc", "type": "UINT16", "scale": 1},
|
||||||
|
41001: {"key": "MaxSoc", "type": "UINT16", "scale": 1},
|
||||||
|
41002: {"key": "MaximumChargingCurrent", "type": "UINT16", "scale": 1},
|
||||||
|
41003: {"key": "MaximumDischargingCurrent", "type": "UINT16", "scale": 1},
|
||||||
|
41004: {"key": "MaxChargeVoltage", "type": "UINT16", "scale": 1},
|
||||||
|
44000: {"key": "GridSetPoint", "type": "INT32", "scale": 1}, # 44000+44001
|
||||||
|
44002: {"key": "EnableGridExport", "type": "UINT16", "scale": 1},
|
||||||
|
44003: {"key": "GridExportPercentage", "type": "INT16", "scale": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
def combine_words(low, high, signed=False):
|
||||||
|
val = (high << 16) | low
|
||||||
|
if signed and val & 0x80000000:
|
||||||
|
val -= 0x100000000
|
||||||
|
return val
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(values):
|
||||||
|
reg_address = address + i
|
||||||
|
reg_info = WRITE_REGISTERS.get(reg_address)
|
||||||
|
|
||||||
|
if reg_info:
|
||||||
|
key = reg_info["key"]
|
||||||
|
dtype = reg_info["type"]
|
||||||
|
scale = reg_info.get("scale", 1)
|
||||||
|
|
||||||
|
if dtype in ("UINT16", "INT16"):
|
||||||
|
raw = values[i]
|
||||||
|
val = raw * scale
|
||||||
|
if dtype == "INT16" and raw & 0x8000:
|
||||||
|
val = raw - 0x10000
|
||||||
|
updates[key] = val
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
elif dtype == "INT32" and i + 1 < len(values):
|
||||||
|
low = values[i]
|
||||||
|
high = values[i + 1]
|
||||||
|
val = combine_words(low, high, signed=True) * scale
|
||||||
|
updates[key] = val
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
logging.warning(f"Incomplete write for {reg_address}")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.config_json):
|
||||||
|
with open(self.config_json, "r") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
else:
|
||||||
|
config_data = {}
|
||||||
|
|
||||||
|
# Update only the relevant keys, keep rest unchanged
|
||||||
|
for k, v in updates.items():
|
||||||
|
config_data[k] = v
|
||||||
|
|
||||||
|
with open(self.config_json, "w") as f:
|
||||||
|
json.dump(config_data, f, indent=4)
|
||||||
|
|
||||||
|
logging.info(f"Updated {self.config_json} keys: {list(updates.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to update {self.config_json}: {e}")
|
||||||
|
|
||||||
|
class JSONFileChangeHandler(FileSystemEventHandler):
|
||||||
|
def __init__(self, store, json_file):
|
||||||
|
self.store = store
|
||||||
|
self.json_file = os.path.abspath(json_file)
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
if os.path.abspath(event.src_path) == self.json_file:
|
||||||
|
logging.info(f"File {self.json_file} changed, reloading...")
|
||||||
|
self.store.reload_json()
|
||||||
|
|
||||||
|
def create_server(data_json,config_json):
|
||||||
|
data_json = os.path.abspath(data_json)
|
||||||
|
config_json = os.path.abspath(config_json)
|
||||||
|
|
||||||
|
logging.info(f"Starting Modbus server with data JSON: {data_json}")
|
||||||
|
logging.info(f"Modbus writable config file: {config_json}")
|
||||||
|
|
||||||
|
store = ModbusDataStore(data_json, config_json)
|
||||||
|
|
||||||
|
slave = ModbusSlaveContext(
|
||||||
|
hr=store,
|
||||||
|
ir=store,
|
||||||
|
)
|
||||||
|
context = ModbusServerContext(slaves=slave, single=True)
|
||||||
|
|
||||||
|
# file change observer for telemetry JSON remains the same
|
||||||
|
event_handler = JSONFileChangeHandler(store, data_json)
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(event_handler, path=os.path.dirname(data_json), recursive=False)
|
||||||
|
observer.start()
|
||||||
|
|
||||||
|
server = ModbusTcpServer(context, address=("0.0.0.0", 502))
|
||||||
|
logging.info("Modbus TCP Server running on port:502")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error running the Modbus server: {e}")
|
||||||
|
finally:
|
||||||
|
observer.stop()
|
||||||
|
observer.join()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Option 1: Absolute paths (uncomment to use specific deployment)
|
||||||
|
# SodistoreHome
|
||||||
|
# json_path = "/home/inesco/SodiStoreHome/ModbusTCP/modbus_tcp_data.json"
|
||||||
|
# config_path = "/home/inesco/SodiStoreHome/config.json"
|
||||||
|
|
||||||
|
# SodistoreMax
|
||||||
|
# json_path = "/home/ie-entwicklung/salimax/ModbusTCP/modbus_tcp_data.json"
|
||||||
|
# config_path = "/home/ie-entwicklung/salimax/config.json"
|
||||||
|
|
||||||
|
# Option 2: Relative to script/binary location (recommended for portability)
|
||||||
|
# Works for both Python script and PyInstaller binary
|
||||||
|
# Script location: /path/to/product/ModbusTCP/modbus_tcp_server.py
|
||||||
|
# Binary location: /path/to/product/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
# JSON location: /path/to/product/ModbusTCP/modbus_tcp_data.json
|
||||||
|
# Config location: /path/to/product/config.json
|
||||||
|
|
||||||
|
# Detect if running as PyInstaller binary or Python script
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as PyInstaller binary: use sys.executable path
|
||||||
|
# Binary is in: /path/to/product/ModbusTCP/dist/modbus_tcp_server
|
||||||
|
# Need to go up one level to get to ModbusTCP folder
|
||||||
|
current_file_dir = Path(sys.executable).resolve().parent.parent
|
||||||
|
else:
|
||||||
|
# Running as Python script: use __file__ path
|
||||||
|
current_file_dir = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
json_path = current_file_dir / "modbus_tcp_data.json"
|
||||||
|
config_path = current_file_dir.parent / "config.json"
|
||||||
|
|
||||||
|
# Convert Path objects to strings
|
||||||
|
json_path = str(json_path)
|
||||||
|
config_path = str(config_path)
|
||||||
|
|
||||||
|
if not os.path.isfile(json_path):
|
||||||
|
logging.error(f"Modbus data JSON file not found: {json_path}. Server cannot start.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.isfile(config_path):
|
||||||
|
logging.error(f"Config JSON file not found: {config_path}. Server cannot start.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
create_server(json_path,config_path)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['modbus_tcp_server.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='modbus_tcp_server',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue