244 lines
9.4 KiB
Python
Executable File
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)
|
|
|