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