Innovenergy_trunk/ModbusTCP/kaco/modbus_tcp_server.py

259 lines
10 KiB
Python

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