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