diff -r db68cb0e6bdc -r 7575050a80c5 runtime/Modbus_config.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/Modbus_config.py Mon Jun 01 08:54:26 2020 +0100 @@ -0,0 +1,569 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz runtime. +# +# Copyright (C) 2020: Mario de Sousa +# +# See COPYING.Runtime file for copyrights details. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + + + +############################################################################################## +# This file implements an extension to the web server embedded in the Beremiz_service.py # +# runtime manager (webserver is in runtime/NevowServer.py). # +# # +# The extension implemented in this file allows for runtime configuration # +# of Modbus plugin parameters # +############################################################################################## + + + +import json +import os +import ctypes +import string +import hashlib + +from formless import annotate, webform + + + +# reference to the PLCObject in runtime/PLCObject.py +# PLCObject is a singleton, created in runtime/__init__.py +_plcobj = None + +# reference to the Nevow web server (a.k.a as NS in Beremiz_service.py) +# (Note that NS will reference the NevowServer.py _module_, and not an object/class) +_NS = None + + +# WorkingDir: the directory on which Beremiz_service.py is running, and where +# all the files downloaded to the PLC get stored +_WorkingDir = None + +# Directory in which to store the persistent configurations +# Should be a directory that does not get wiped on reboot! +_ModbusConfFiledir = "/tmp" + +# Will contain references to the C functions +# (implemented in beremiz/modbus/mb_runtime.c) +# used to get/set the Modbus specific configuration paramters +GetParamFuncs = {} +SetParamFuncs = {} + + +# List of all TCP clients configured in the loaded PLC (i.e. the .so file loaded into memory) +# Each entry will be a dictionary. See _Add_TCP_Client() for the data structure details... +_TCPclient_list = [] + + + + +# Paramters we will need to get from the C code, but that will not be shown +# on the web interface. Common to all modbus entry types (client/server, tcp/rtu/ascii) +General_parameters = [ + # param. name label ctype type annotate type + # (C code var name) (used on web interface) (C data type) (web data type) + # (annotate.String, + # annotate.Integer, ...) + ("config_name" , _("") , ctypes.c_char_p, annotate.String), + ("addr_type" , _("") , ctypes.c_char_p, annotate.String) + ] + +TCPclient_parameters = [ + # param. name label ctype type annotate type + # (C code var name) (used on web interface) (C data type) (web data type) + # (annotate.String, + # annotate.Integer, ...) + ("host" , _("Remote IP Address") , ctypes.c_char_p, annotate.String), + ("port" , _("Remote Port Number") , ctypes.c_char_p, annotate.String), + ("comm_period" , _("Invocation Rate (ms)") , ctypes.c_ulonglong, annotate.Integer) + ] + +RTUclient_parameters = [ + # param. name label ctype type annotate type + # (C code var name) (used on web interface) (C data type) (web data type) + # (annotate.String, + # annotate.Integer, ...) + ("device" , _("Serial Port") , ctypes.c_char_p, annotate.String), + ("baud" , _("Baud Rate") , ctypes.c_int, annotate.Integer), + ("parity" , _("Parity") , ctypes.c_int, annotate.Integer), + ("stop_bits" , _("Stop Bits") , ctypes.c_int, annotate.Integer), + ("comm_period" , _("Invocation Rate (ms)") , ctypes.c_ulonglong, annotate.Integer) + ] + + +# Note: the dictionary key must be the same as the string returned by the +# __modbus_get_ClientNode_addr_type() +# __modbus_get_ServerNode_addr_type() +# functions implemented in C (see modbus/mb_runtime.c) +_client_parameters = {} +_client_parameters["tcp" ] = TCPclient_parameters +_client_parameters["rtu" ] = RTUclient_parameters +_client_parameters["ascii"] = [] # (Note: ascii not yet implemented in Beremiz modbus plugin) + + +#def _CheckPortnumber(port_number): +# """ check validity of the port number """ +# try: +# portnum = int(port_number) +# if (portnum < 0) or (portnum > 65535): +# raise Exception +# except Exception: +# return False +# +# return True + + + + +#def _CheckConfiguration(BACnetConfig): +# res = True +# res = res and _CheckPortnumber(BACnetConfig["port_number"]) +# res = res and _CheckDeviceID (BACnetConfig["device_id"]) +# return res + + + +#def _CheckWebConfiguration(BACnetConfig): +# res = True +# +# # check the port number +# if not _CheckPortnumber(BACnetConfig["port_number"]): +# raise annotate.ValidateError( +# {"port_number": "Invalid port number: " + str(BACnetConfig["port_number"])}, +# _("Modbus configuration error:")) +# res = False +# +# if not _CheckDeviceID(BACnetConfig["device_id"]): +# raise annotate.ValidateError( +# {"device_id": "Invalid device ID: " + str(BACnetConfig["device_id"])}, +# _("Modbus configuration error:")) +# res = False +# +# return res + + + + + + +def _SetSavedConfiguration(node_id, newConfig): + """ Stores a dictionary in a persistant file containing the Modbus parameter configuration """ + + filename = _TCPclient_list[node_id]["filename"] + + with open(os.path.realpath(filename), 'w') as f: + json.dump(newConfig, f, sort_keys=True, indent=4) + + _TCPclient_list[node_id]["SavedConfiguration"] = newConfig + + + + +def _DelSavedConfiguration(node_id): + """ Deletes the file cotaining the persistent Modbus configuration """ + filename = _TCPclient_list[node_id]["filename"] + + if os.path.exists(filename): + os.remove(filename) + + + + +def _GetSavedConfiguration(node_id): + """ + Returns a dictionary containing the Modbus parameter configuration + that was last saved to file. If no file exists, then return None + """ + filename = _TCPclient_list[node_id]["filename"] + try: + #if os.path.isfile(filename): + saved_config = json.load(open(filename)) + except Exception: + return None + + #if _CheckConfiguration(saved_config): + # return saved_config + #else: + # return None + + return saved_config + + + +def _GetPLCConfiguration(node_id): + """ + Returns a dictionary containing the current Modbus parameter configuration + stored in the C variables in the loaded PLC (.so file) + """ + current_config = {} + addr_type = _TCPclient_list[node_id]["addr_type"] + + for par_name, x1, x2, x3 in _client_parameters[addr_type]: + value = GetParamFuncs[par_name](node_id) + if value is not None: + current_config[par_name] = value + + return current_config + + + +def _SetPLCConfiguration(node_id, newconfig): + """ + Stores the Modbus parameter configuration into the + the C variables in the loaded PLC (.so file) + """ + addr_type = _TCPclient_list[node_id]["addr_type"] + + for par_name in newconfig: + value = newconfig[par_name] + if value is not None: + SetParamFuncs[par_name](node_id, value) + + + + +def _GetWebviewConfigurationValue(ctx, node_id, argument): + """ + Callback function, called by the web interface (NevowServer.py) + to fill in the default value of each parameter of the web form + + Note that the real callback function is a dynamically created function that + will simply call this function to do the work. It will also pass the node_id + as a parameter. + """ + try: + return _TCPclient_list[node_id]["WebviewConfiguration"][argument.name] + except Exception: + return "" + + + + +def _updateWebInterface(node_id): + """ + Add/Remove buttons to/from the web interface depending on the current state + - If there is a saved state => add a delete saved state button + """ + + config_hash = _TCPclient_list[node_id]["config_hash"] + config_name = _TCPclient_list[node_id]["config_name"] + + # Add a "Delete Saved Configuration" button if there is a saved configuration! + if _TCPclient_list[node_id]["SavedConfiguration"] is None: + _NS.ConfigurableSettings.delSettings("ModbusConfigDelSaved" + config_hash) + else: + def __OnButtonDel(**kwargs): + return OnButtonDel(node_id = node_id, **kwargs) + + _NS.ConfigurableSettings.addSettings( + "ModbusConfigDelSaved" + config_hash, # name (internal, may not contain spaces, ...) + _("Modbus Configuration: ") + config_name, # description (user visible label) + [], # fields (empty, no parameters required!) + _("Delete Configuration Stored in Persistent Storage"), # button label + __OnButtonDel, # callback + "ModbusConfigParm" + config_hash) # Add after entry xxxx + + + +def OnButtonSave(**kwargs): + """ + Function called when user clicks 'Save' button in web interface + The function will configure the Modbus plugin in the PLC with the values + specified in the web interface. However, values must be validated first! + + Note that this function does not get called directly. The real callback + function is the dynamic __OnButtonSave() function, which will add the + "node_id" argument, and call this function to do the work. + """ + + #_plcobj.LogMessage("Modbus web server extension::OnButtonSave() Called") + + newConfig = {} + node_id = kwargs.get("node_id", None) + addr_type = _TCPclient_list[node_id]["addr_type"] + + for par_name, x1, x2, x3 in _client_parameters[addr_type]: + value = kwargs.get(par_name, None) + if value is not None: + newConfig[par_name] = value + + _TCPclient_list[node_id]["WebviewConfiguration"] = newConfig + + # First check if configuration is OK. + ## TODO... + #if not _CheckWebConfiguration(newConfig): + # return + + # store to file the new configuration so that + # we can recoup the configuration the next time the PLC + # has a cold start (i.e. when Beremiz_service.py is retarted) + _SetSavedConfiguration(node_id, newConfig) + + # Configure PLC with the current Modbus parameters + _SetPLCConfiguration(node_id, newConfig) + + # File has just been created => Delete button must be shown on web interface! + _updateWebInterface(node_id) + + + + +def OnButtonDel(**kwargs): + """ + Function called when user clicks 'Delete' button in web interface + The function will delete the file containing the persistent + Modbus configution + """ + + node_id = kwargs.get("node_id", None) + + # Delete the file + _DelSavedConfiguration(node_id) + + # Set the current configuration to the default (hardcoded in C) + new_config = _TCPclient_list[node_id]["DefaultConfiguration"] + _SetPLCConfiguration(node_id, new_config) + + #Update the webviewconfiguration + _TCPclient_list[node_id]["WebviewConfiguration"] = new_config + + # Reset SavedConfiguration + _TCPclient_list[node_id]["SavedConfiguration"] = None + + # File has just been deleted => Delete button on web interface no longer needed! + _updateWebInterface(node_id) + + + + +def OnButtonShowCur(**kwargs): + """ + Function called when user clicks 'Show Current PLC Configuration' button in web interface + The function will load the current PLC configuration into the web form + + Note that this function does not get called directly. The real callback + function is the dynamic __OnButtonShowCur() function, which will add the + "node_id" argument, and call this function to do the work. + """ + node_id = kwargs.get("node_id", None) + + _TCPclient_list[node_id]["WebviewConfiguration"] = _GetPLCConfiguration(node_id) + + + + +def _Load_TCP_Client(node_id): + TCPclient_entry = {} + + config_name = GetParamFuncs["config_name"](node_id) + # addr_type will be one of "tcp", "rtu" or "ascii" + addr_type = GetParamFuncs["addr_type" ](node_id) + # For some operations we cannot use the config name (e.g. filename to store config) + # because the user may be using characters that are invalid for that purpose ('/' for + # example), so we create a hash of the config_name, and use that instead. + config_hash = hashlib.md5(config_name).hexdigest() + + _plcobj.LogMessage("Modbus web server extension::_Load_TCP_Client("+str(node_id)+") config_name="+config_name) + + # Add the new entry to the global list + # Note: it is OK, and actually necessary, to do this _before_ seting all the parameters in TCPclient_entry + # TCPclient_entry will be stored as a reference, so we can insert parameters at will. + global _TCPclient_list + _TCPclient_list.append(TCPclient_entry) + + # store all node_id relevant data for future reference + TCPclient_entry["node_id" ] = node_id + TCPclient_entry["config_name" ] = config_name + TCPclient_entry["addr_type" ] = addr_type + TCPclient_entry["config_hash" ] = config_hash + TCPclient_entry["filename" ] = os.path.join(_ModbusConfFiledir, "Modbus_config_" + config_hash + ".json") + + # Dictionary that contains the Modbus configuration currently being shown + # on the web interface + # This configuration will almost always be identical to the current + # configuration in the PLC (i.e., the current state stored in the + # C variables in the .so file). + # The configuration viewed on the web will only be different to the current + # configuration when the user edits the configuration, and when + # the user asks to save an edited configuration that contains an error. + TCPclient_entry["WebviewConfiguration"] = None + + # Upon PLC load, this Dictionary is initialised with the Modbus configuration + # hardcoded in the C file + # (i.e. the configuration inserted in Beremiz IDE when project was compiled) + TCPclient_entry["DefaultConfiguration"] = _GetPLCConfiguration(node_id) + TCPclient_entry["WebviewConfiguration"] = TCPclient_entry["DefaultConfiguration"] + + # Dictionary that stores the Modbus configuration currently stored in a file + # Currently only used to decide whether or not to show the "Delete" button on the + # web interface (only shown if _SavedConfiguration is not None) + SavedConfig = _GetSavedConfiguration(node_id) + TCPclient_entry["SavedConfiguration"] = SavedConfig + + if SavedConfig is not None: + _SetPLCConfiguration(node_id, SavedConfig) + TCPclient_entry["WebviewConfiguration"] = SavedConfig + + # Define the format for the web form used to show/change the current parameters + # We first declare a dynamic function to work as callback to obtain the default values for each parameter + def __GetWebviewConfigurationValue(ctx, argument): + return _GetWebviewConfigurationValue(ctx, node_id, argument) + + webFormInterface = [(name, web_dtype (label=web_label, default=__GetWebviewConfigurationValue)) + for name, web_label, c_dtype, web_dtype in _client_parameters[addr_type]] + + # Configure the web interface to include the Modbus config parameters + def __OnButtonSave(**kwargs): + OnButtonSave(node_id=node_id, **kwargs) + + _NS.ConfigurableSettings.addSettings( + "ModbusConfigParm" + config_hash, # name (internal, may not contain spaces, ...) + _("Modbus Configuration: ") + config_name, # description (user visible label) + webFormInterface, # fields + _("Save Configuration to Persistent Storage"), # button label + __OnButtonSave) # callback + + # Add a "View Current Configuration" button + def __OnButtonShowCur(**kwargs): + OnButtonShowCur(node_id=node_id, **kwargs) + + _NS.ConfigurableSettings.addSettings( + "ModbusConfigViewCur" + config_hash, # name (internal, may not contain spaces, ...) + _("Modbus Configuration: ") + config_name, # description (user visible label) + [], # fields (empty, no parameters required!) + _("Show Current PLC Configuration"), # button label + __OnButtonShowCur) # callback + + # Add the Delete button to the web interface, if required + _updateWebInterface(node_id) + + + + +def OnLoadPLC(): + """ + Callback function, called (by PLCObject.py) when a new PLC program + (i.e. XXX.so file) is transfered to the PLC runtime + and loaded into memory + """ + + #_plcobj.LogMessage("Modbus web server extension::OnLoadPLC() Called...") + + if _plcobj.PLClibraryHandle is None: + # PLC was loaded but we don't have access to the library of compiled code (.so lib)? + # Hmm... This shold never occur!! + return + + # Get the number of Modbus Client and Servers (Modbus plugin) + # configured in the currently loaded PLC project (i.e., the .so file) + # If the "__modbus_plugin_client_node_count" + # or the "__modbus_plugin_server_node_count" C variables + # are not present in the .so file we conclude that the currently loaded + # PLC does not have the Modbus plugin included (situation (2b) described above init()) + try: + client_count = ctypes.c_int.in_dll(_plcobj.PLClibraryHandle, "__modbus_plugin_client_node_count").value + server_count = ctypes.c_int.in_dll(_plcobj.PLClibraryHandle, "__modbus_plugin_server_node_count").value + except Exception: + # Loaded PLC does not have the Modbus plugin => nothing to do + # (i.e. do _not_ configure and make available the Modbus web interface) + return + + if client_count < 0: client_count = 0 + if server_count < 0: server_count = 0 + + if (client_count == 0) and (server_count == 0): + # The Modbus plugin in the loaded PLC does not have any client and servers configured + # => nothing to do (i.e. do _not_ configure and make available the Modbus web interface) + return + + # Map the get/set functions (written in C code) we will be using to get/set the configuration parameters + for name, web_label, c_dtype, web_dtype in TCPclient_parameters + RTUclient_parameters + General_parameters: + GetParamFuncName = "__modbus_get_ClientNode_" + name + GetParamFuncs[name] = getattr(_plcobj.PLClibraryHandle, GetParamFuncName) + GetParamFuncs[name].restype = c_dtype + GetParamFuncs[name].argtypes = [ctypes.c_int] + + for name, web_label, c_dtype, web_dtype in TCPclient_parameters + RTUclient_parameters: + SetParamFuncName = "__modbus_set_ClientNode_" + name + SetParamFuncs[name] = getattr(_plcobj.PLClibraryHandle, SetParamFuncName) + SetParamFuncs[name].restype = None + SetParamFuncs[name].argtypes = [ctypes.c_int, c_dtype] + + for node_id in range(client_count): + _Load_TCP_Client(node_id) + + + + + + +def OnUnLoadPLC(): + """ + # Callback function, called (by PLCObject.py) when a PLC program is unloaded from memory + """ + + #_plcobj.LogMessage("Modbus web server extension::OnUnLoadPLC() Called...") + + # Delete the Modbus specific web interface extensions + # (Safe to ask to delete, even if it has not been added!) + global _TCPclient_list + for TCPclient_entry in _TCPclient_list: + config_hash = TCPclient_entry["config_hash"] + _NS.ConfigurableSettings.delSettings("ModbusConfigParm" + config_hash) + _NS.ConfigurableSettings.delSettings("ModbusConfigViewCur" + config_hash) + _NS.ConfigurableSettings.delSettings("ModbusConfigDelSaved" + config_hash) + + # Dele all entries... + _TCPclient_list = [] + + + +# The Beremiz_service.py service, along with the integrated web server it launches +# (i.e. Nevow web server, in runtime/NevowServer.py), will go through several states +# once started: +# (1) Web server is started, but no PLC is loaded +# (2) PLC is loaded (i.e. the PLC compiled code is loaded) +# (a) The loaded PLC includes the Modbus plugin +# (b) The loaded PLC does not have the Modbus plugin +# +# During (1) and (2a): +# we configure the web server interface to not have the Modbus web configuration extension +# During (2b) +# we configure the web server interface to include the Modbus web configuration extension +# +# PS: reference to the pyroserver (i.e., the server object of Beremiz_service.py) +# (NOTE: PS.plcobj is a reference to PLCObject.py) +# NS: reference to the web server (i.e. the NevowServer.py module) +# WorkingDir: the directory on which Beremiz_service.py is running, and where +# all the files downloaded to the PLC get stored, including +# the .so file with the compiled C generated code +def init(plcobj, NS, WorkingDir): + #PS.plcobj.LogMessage("Modbus web server extension::init(PS, NS, " + WorkingDir + ") Called") + global _WorkingDir + _WorkingDir = WorkingDir + global _plcobj + _plcobj = plcobj + global _NS + _NS = NS + + _plcobj.RegisterCallbackLoad ("Modbus_Settins_Extension", OnLoadPLC) + _plcobj.RegisterCallbackUnLoad("Modbus_Settins_Extension", OnUnLoadPLC) + OnUnLoadPLC() # init is called before the PLC gets loaded... so we make sure we have the correct state