diff --git a/ModbusTCP/ModbusTCP.service-SodistoreMax b/ModbusTCP/ModbusTCP.service-SodistoreMax new file mode 100644 index 000000000..f205f6688 --- /dev/null +++ b/ModbusTCP/ModbusTCP.service-SodistoreMax @@ -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 diff --git a/ModbusTCP/ModusTCP.service-SodistoreHome b/ModbusTCP/ModusTCP.service-SodistoreHome new file mode 100644 index 000000000..832a1a53c --- /dev/null +++ b/ModbusTCP/ModusTCP.service-SodistoreHome @@ -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 \ No newline at end of file diff --git a/ModbusTCP/build_and_deploy_sodistorehome.sh b/ModbusTCP/build_and_deploy_sodistorehome.sh new file mode 100755 index 000000000..2ac30cc69 --- /dev/null +++ b/ModbusTCP/build_and_deploy_sodistorehome.sh @@ -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!" diff --git a/ModbusTCP/dist/modbus_tcp_server b/ModbusTCP/dist/modbus_tcp_server new file mode 100755 index 000000000..6b1c5d9b9 Binary files /dev/null and b/ModbusTCP/dist/modbus_tcp_server differ diff --git a/ModbusTCP/kaco/Setting b/ModbusTCP/kaco/Setting new file mode 100644 index 000000000..e69de29bb diff --git a/ModbusTCP/kaco/modbus_tcp_client.py b/ModbusTCP/kaco/modbus_tcp_client.py new file mode 100644 index 000000000..ac1456869 --- /dev/null +++ b/ModbusTCP/kaco/modbus_tcp_client.py @@ -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() diff --git a/ModbusTCP/kaco/modbus_tcp_data.json b/ModbusTCP/kaco/modbus_tcp_data.json new file mode 100644 index 000000000..8b239c48c --- /dev/null +++ b/ModbusTCP/kaco/modbus_tcp_data.json @@ -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 +} diff --git a/ModbusTCP/kaco/modbus_tcp_server.py b/ModbusTCP/kaco/modbus_tcp_server.py new file mode 100644 index 000000000..42e6cf94c --- /dev/null +++ b/ModbusTCP/kaco/modbus_tcp_server.py @@ -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) diff --git a/ModbusTCP/modbus_tcp_client.py b/ModbusTCP/modbus_tcp_client.py new file mode 100644 index 000000000..86921feff --- /dev/null +++ b/ModbusTCP/modbus_tcp_client.py @@ -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() diff --git a/ModbusTCP/modbus_tcp_data.json b/ModbusTCP/modbus_tcp_data.json new file mode 100644 index 000000000..e20437a47 --- /dev/null +++ b/ModbusTCP/modbus_tcp_data.json @@ -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 +} \ No newline at end of file diff --git a/ModbusTCP/modbus_tcp_server.py b/ModbusTCP/modbus_tcp_server.py new file mode 100755 index 000000000..5f42d7c2e --- /dev/null +++ b/ModbusTCP/modbus_tcp_server.py @@ -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) + diff --git a/ModbusTCP/modbus_tcp_server.spec b/ModbusTCP/modbus_tcp_server.spec new file mode 100644 index 000000000..593abed14 --- /dev/null +++ b/ModbusTCP/modbus_tcp_server.spec @@ -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, +)