sodistore home modbust tcp service source code

This commit is contained in:
Yinyin Liu 2026-04-28 13:26:05 +02:00
parent bee5d8e1e7
commit bb1efaf0e9
12 changed files with 1255 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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!"

BIN
ModbusTCP/dist/modbus_tcp_server vendored Executable file

Binary file not shown.

0
ModbusTCP/kaco/Setting Normal file
View File

View File

@ -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()

View File

@ -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
}

View File

@ -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)

View File

@ -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()

View File

@ -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
}

243
ModbusTCP/modbus_tcp_server.py Executable file
View File

@ -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)

View File

@ -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,
)