#!/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)