Innovenergy_trunk/ModbusTCP/modbus_tcp_server.py

244 lines
9.4 KiB
Python
Executable File

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