# coding=UTF-8 ## @package pymodmon # Python Modbus Monitor # a small program that uses the pymodbus package to retrieve and # display modbus slave data. # requires: Python 2.7, pymodbus, docopt # # Date created: 2016-05-04 # Author: André Schieleit ## help message to display by docopt (and parsed by docopt for command line arguments) '''Python Modbus Monitor. Usage: pymodmon.py pymodmon.py [-h|--help] pymodmon.py [--version] pymodmon.py -i <file>|--inifile=<file> [-l <file>|--logfile=<file>] [-L <sec>|--loginterval=<sec>] [-B <buf>|--logbuffer=<buf>] [-S|--single] [--nogui] [-D|--daily-log] pymodmon.py --ip=<IP-address> --port=<port> --id=<id> --addr=<adr> --type=<TYPE> --format=<FORM> [-L <sec>|--loginterval=<sec>] [-B <buf>|--logbuffer=<buf>] [--descr=<"descr">] [--unit=<"unit">] [-S|--single] [-l <file>|--logfile=<file>] Options: no options given in a xterm will open the TK interface -h, --help Show this screen --version Show version -i, --inifile=<file> Uses the given file as input for communication and log file settings and channel configuration -l, --logfile=<file> Uses the given file as output for the retrieved data. The data will be formatted in csv. Existing files will be appended. --ip=<IP-address> Use this as the IP address of the communication target --port=<port> Port of the communication target --id=<id> Modbus ID of the communication target --addr=<adr> Address of the modbus register to read --type=<TYPE> Data type of the retrieved data at a given address. Allowed types: U64, U32, U16, S32, S16, STR32 --format=<FORM> Format of the retrieved data. Allowed formats: RAW, UTF8, FIX0, FIX1, FIX2, FIX3 --descr=<descr> Description for the retrieved data. e.g. --descr="device name" --unit=<unit> Unit of the retrieved data. e.g. --unit="V" -G, --nogui Explicitly run without gui even when available -S, --single Do only one read cycle instead of continuous reading. -L, --loginterval=<sec> Read data every xx seconds. [defaul value: 5] -B, --logbuffer=<buf> Read xx datasets before writing to disk. Useful to prevent wearout on solid state devices. [default value: 50] -D, --daily-log Writes a log file for each day. At 00:00:00 system time a new log file will be started. A given log file name will be appended with the current date with "%Y-%m-%d" format. ''' ## use docopt for command line parsing and displaying help message try: import docopt except ImportError: try: ## for command line showerror does not work showerror('Import Error','docopt package was not found on your system.\nPlease install it using the command:\ \n"pip install docopt"') except: print ('Import errror. docopt package was not found on your system. Please install it using the command: "pip install docopt"') from docopt import docopt if __name__ == '__main__': arguments = docopt(__doc__, version='PyModMon 1.0') ## use pymodbus for the Modbus communication try: from pymodbus import * except ImportError: try: ## for command line showerror does not work showerror('Import Error','pymodbus package was not found on your system.\nPlease install it using the command:\ \n"pip install pymodbus"') except: print ('Import errror. pymodbus package was not found on your system. Please install it using the command: "pip install pymodbus"') ## enable execution of functions on program exit import atexit ## enable timed execution of the data polling from threading import Timer ## enable file access import os ## class for all data related things # class Data(object): ## set default values and allowed input values def __init__(self): self.inifilename = None self.logfilename = None self.logmaxbuffer = 50 ## how many records will be buffered before writing to file self.ipaddress = '10.0.0.42' ## address of the communication target self.portno = 502 ## port number of the target self.modbusid = 3 ## bus ID of the target self.manufacturer = 'Default Manufacturer' ## arbitrary string for user conveniance self.loginterval = 5 ## how often should data be pulled from target in seconds self.moddatatype = { ## allowed data types, sent from target 'S32':2, 'U32':2, 'U64':4, 'STR32':16, 'S16':1, 'U16':1 } self.dataformat = ['ENUM','UTF8','FIX3','FIX2','FIX1','FIX0','RAW'] ## data format from target ## table of data to be pulled from target self.datasets = [['address','type','format','description','unit','value']] self.datavector = [] ## holds the polled data from target self.databuffer = [] ## holds the datavectors before writing to disk self.datawritebuffer = [] ## holds a copy of databuffer for actual writing to disk ## class that contains all IO specifics class Inout: ## some values to check against when receiving data from target # these values are read when there is not acutal value from the target available. # they are the equivalent to None MIN_SIGNED = -2147483648 MAX_UNSIGNED = 4294967295 ## function for testing the per command line specified configuration file def checkImportFile(self): ## does the file exist? try: inifile = open(str(arguments['--inifile']),'r').close() data.inifilename = str(arguments['--inifile']) except: ## if we have a GUI display an error dialog try: showerror('Import Error','The specified configuration file was not found.') return except: ## if no GUI display error and exit print('Configuration file error. A file with that name seems not to exist, please check.') exit() try: inout.readImportFile() except: try: showerror('Import Error','Could not read the configuration file. Please check file path and/or file.') return except: print 'Could not read configuration file. Please check file path and/or file.' exit() ## function for acually reading input configuration file def readImportFile(self): ## read config data from file import ConfigParser Config = ConfigParser.SafeConfigParser() ## read the config file Config.read(data.inifilename) data.ipaddress = Config.get('CommSettings','IP address') data.portno = int(Config.get('CommSettings','port number')) data.modbusid = int(Config.get('CommSettings','Modbus ID')) data.manufacturer = Config.get('CommSettings','manufacturer') data.loginterval = int(Config.get('CommSettings','logger interval')) try: ## logfilename may be empty. if so data will printed to terminal data.logfilename = Config.get('FileSettings','log file') except: data.logfilename = None data.logmaxbuffer = int(Config.get('FileSettings','log buffer')) data.datasets = eval(Config.get('TargetDataSettings','data table')) ## function for actually writing configuration data # def writeExportFile(self): ## use ini file capabilities import ConfigParser Config = ConfigParser.ConfigParser() ## if the dialog was closed with no file selected ('cancel') just return if (data.inifilename == None): try: ## if running in command line no window can be displayed showerror('Configuration File Error','no file name given, please check.') except: print('Configuration file error, no file name given, please check.') return ## write the data to the selected config file try: inifile = open(data.inifilename,'w') except: try: ## if running in command line no window can be displayed showerror('Configuration File Error','a file with that name seems not to exist, please check.') except: print('Configuration file error, a file with that name seems not to exist, please check.') gui.selectExportFile() return ## format the file structure Config.add_section('CommSettings') Config.set('CommSettings','IP address',data.ipaddress) Config.set('CommSettings','port number',data.portno) Config.set('CommSettings','Modbus ID',data.modbusid) Config.set('CommSettings','manufacturer',data.manufacturer) Config.set('CommSettings','logger interval',data.loginterval) Config.add_section('FileSettings') Config.set('FileSettings','log file',data.logfilename) Config.set('FileSettings','log buffer',data.logmaxbuffer) Config.add_section('TargetDataSettings') Config.set('TargetDataSettings','data table',data.datasets) Config.write(inifile) inifile.close() ## function for writing to log file # checks if it is an existing file with data in it and will append then # this function should only be called in intervals writing data in bulk (e.g. every 5 minutes) # to prevent wearout on solid state disks like SD CARDs # def writeLoggerDataFile(self): import csv ## for writing in csv format import datetime ## for daily log option if (data.logfilename == None): ## when no filename is given, print data to terminal if len(data.datawritebuffer) > 0: ## if the buffer has data write this to terminal print (data.datawritebuffer) data.datawritebuffer = [] ## empty buffer else: ## we asume that this was called outside the poll loop with buffer size not reached print(data.databuffer) if (len(data.databuffer) == 1): ## if only one address was provided via command line print data.databuffer[0][0],data.datasets[1][3],data.databuffer[0][1],data.datasets[1][4] data.databuffer = [] ## empty buffer return thislogfile = data.logfilename ## store filename locally for daily option thisdate = str(datetime.date.today()) gui_checked_daily = 0 ## is GUI available or is command line mode active if (gui_active): gui_checked_daily = int(gui.checked_daily.get()) ## if daily option is active if ( (arguments['--daily-log'] == True) or (gui_checked_daily) ): ## assumption: the logfile has a file extension logfileparts = thislogfile.rpartition('.') ## if there is no file extension we will append the current date to the name if (logfileparts[0] == ''): ## re-order logfileparts thislogfile = logfileparts[2]+'_'+thisdate else: ## format the new logfilename with current date included thislogfile = logfileparts[0]+'_'+thisdate+logfileparts[1]+logfileparts[2] ## try to open the file. if it does not exist, create it on the way try: open(thislogfile, 'a').close() except: try: ## if running in command line no window can be displayed showerror('Log File Error','file cannot be accessed, please check.') except: print('Log file error. File cannot be accessed, please check.') return ## check if the file is empty, if so write the header information to the file if os.stat(thislogfile).st_size==0: with open(thislogfile,'ab') as logfile: logwriter = csv.writer(logfile, quoting=csv.QUOTE_ALL) ## ensure UTF8 encoding while writing ## print out what data is contained and whats its format for thisrow in data.datasets: thisrow = [s.encode('utf-8') for s in thisrow] logwriter.writerows([thisrow]) logfile.write('-'*50+'\n') ## write a separator ## format the column headers columnheader = 'time' for thisrow in data.datasets[1:]: ## omit first row containing 'address' ## ensure UTF8 encoding while writing, since this is the column heading # no problem with converting to string even if stored as int thisrow = [s.encode('utf-8') for s in thisrow] ## use description field for columnheader if filled if (thisrow[3] != ''): thisdescription = ','+str(thisrow[3]) ## if a unit is entered add it after the description if (thisrow[4] != ''): thisdescription += ' ('+str(thisrow[4])+')' columnheader += thisdescription else: ## no description, use address as header columnheader += ', '+str(thisrow[0]) columnheader += '\n' ## line break before data rows logfile.write(columnheader) ## if the file is not empty we assume an append write to the file if len(data.datawritebuffer) > 0: ## if the buffer has data write this to disk with open(thislogfile,'ab') as logfile: logwriter = csv.writer(logfile) logwriter.writerows(data.datawritebuffer) data.datawritebuffer = [] ## empty buffer else: ## we asume that this was called outside the poll loop with buffer size not reached with open(thislogfile,'ab') as logfile: logwriter = csv.writer(logfile) logwriter.writerows(data.databuffer) data.databuffer = [] ## empty buffer ## function for starting communication with target # def runCommunication(self): from pymodbus.client.sync import ModbusTcpClient as ModbusClient self.client = ModbusClient(host=data.ipaddress, port=data.portno) try: self.client.connect() except: showerror('Modbus Connection Error','could not connect to target. Check your settings, please.') self.pollTargetData() self.client.close() ## lambda: is required to not spawn hundreds of threads but only one that calls itself self.commtimer = Timer(data.loginterval, lambda: self.runCommunication()) self.commtimer.start() ## needs to be a separate command else the timer is not cancel-able def stopCommunication(self): #print ('Stopped Communication') self.commtimer.cancel() ## flush data buffer to disk self.writeLoggerDataFile() ## function for polling data from the target and triggering writing to log file if set # def pollTargetData(self): from pymodbus.payload import BinaryPayloadDecoder from pymodbus.constants import Endian import datetime data.datavector = [] ## empty datavector for current values ## request each register from datasets, omit first row which contains only column headers for thisrow in data.datasets[1:]: ## if the connection is somehow not possible (e.g. target not responding) # show a error message instead of excepting and stopping try: received = self.client.read_input_registers(address = int(thisrow[0]), count = data.moddatatype[thisrow[1]], unit = data.modbusid) except: thisdate = str(datetime.datetime.now()).partition('.')[0] thiserrormessage = thisdate + ': Connection not possible. Check settings or connection.' if (gui_active): showerror('Connection Error',thiserrormessage) return ## prevent further execution of this function else: print thiserrormessage return ## prevent further execution of this function message = BinaryPayloadDecoder.fromRegisters(received.registers, byteorder=Endian.Big, wordorder=Endian.Big) ## provide the correct result depending on the defined datatype if thisrow[1] == 'S32': interpreted = message.decode_32bit_int() elif thisrow[1] == 'U32': interpreted = message.decode_32bit_uint() elif thisrow[1] == 'U64': interpreted = message.decode_64bit_uint() elif thisrow[1] == 'STR32': interpreted = message.decode_string(32) elif thisrow[1] == 'S16': interpreted = message.decode_16bit_int() elif thisrow[1] == 'U16': interpreted = message.decode_16bit_uint() else: ## if no data type is defined do raw interpretation of the delivered data interpreted = message.decode_16bit_uint() ## check for "None" data before doing anything else if ((interpreted == self.MIN_SIGNED) or (interpreted == self.MAX_UNSIGNED)): displaydata = None else: ## put the data with correct formatting into the data table if thisrow[2] == 'FIX3': displaydata = float(interpreted) / 1000 elif thisrow[2] == 'FIX2': displaydata = float(interpreted) / 100 elif thisrow[2] == 'FIX1': displaydata = float(interpreted) / 10 else: displaydata = interpreted ## save _scaled_ data in datavector for further handling data.datavector.append(displaydata) ## display collected data if (gui_active == 1): gui.updateLoggerDisplay() ## for logging purposes we need a time stamp first stampedvector = [] ## we don't need the microseconds of the date return value, so we strip it stampedvector.append(str(datetime.datetime.now()).partition('.')[0]) stampedvector += data.datavector data.databuffer.append(stampedvector) #print data.databuffer ## is the buffer large enough to be written to file system? if (len(data.databuffer) >= data.logmaxbuffer): ## ensure that the data to write will not be altered by faster poll cycles data.datawritebuffer = data.databuffer data.databuffer = [] ## empty the buffer self.writeLoggerDataFile() ## call write routine to save data on disk ## function adds dataset to the datasets list # also updates the displayed list # new datasets are not added to the config file # def addDataset(self,inputdata): data.datasets.append(inputdata) print 'Current datasets: ',(data.datasets) ## function for saving program state at program exit # def cleanOnExit(self): try: ## stop data logging on exit, catch a possible exception, when communication is not running self.stopCommunication() except: print '' ## if data is available, write polled data from buffer to disk if len(data.databuffer): self.writeLoggerDataFile() print 'PyModMon has exited cleanly.' ## function for printing the current configuration settings # only used for debug purpose # def printConfig(self): counter = 0 for data in data.datasets: print('Datasets in List:', counter, data) counter += 1 ## class that contains all GUI specifics # class Gui: def __init__(self,master): ## configure app window master.title('Python Modbus Monitor') master.minsize(width=550, height=450) master.geometry("550x550") ## scale window a bit bigger for more data lines self.settingscanvas = Canvas(master,bg="yellow",highlightthickness=0) self.settingscanvas.pack(side='top',anchor='nw',expand=False,fill='x') ## make the contents of settingscanvas fit the window width Grid.columnconfigure(self.settingscanvas,0,weight = 1) ## create window containers ## frame for the config file and data logger file display filesframe = Frame(self.settingscanvas,bd=1,relief='groove') filesframe.columnconfigure(1,weight=1) ## set 2nd column to be auto-stretched when window is resized filesframe.grid(sticky = 'EW') ## frame for the settings of the communication parameters self.settingsframe = Frame(self.settingscanvas,bd=1,relief='groove') self.settingsframe.grid(sticky = 'EW') ## frame for the controls for starting and stopping configuration controlframe = Frame(self.settingscanvas,bd=1,relief='groove') controlframe.grid(sticky = 'EW') ## create Menu menubar = Menu(master) filemenu = Menu(menubar, tearoff=0) filemenu.add_command(label='Import Configuration File…',command=self.selectImportFile) filemenu.add_command(label='Export Configuration File…',command=self.selectExportFile) filemenu.add_command(label='Set Logger Data File…',command=self.selectLoggerDataFile) filemenu.add_command(label='Save Current Configuration',command=inout.writeExportFile) filemenu.add_command(label='Exit',command=self.closeWindow) toolmenu = Menu(menubar, tearoff=0) toolmenu.add_command(label='Data Settings…',command=self.dataSettings) toolmenu.add_command(label='Print Config Data',command=inout.printConfig) helpmenu = Menu(menubar, tearoff=0) helpmenu.add_command(label='About…',command=self.aboutDialog) menubar.add_cascade(label='File', menu=filemenu) menubar.add_cascade(label='Tools', menu=toolmenu) menubar.add_cascade(label='Help', menu=helpmenu) master.config(menu=menubar) ## add GUI elements ## input mask for configuration file # Label(filesframe, text='Configuration File:').grid(row=0,sticky='E') self.input_inifilename = Entry(filesframe, width = 40) self.input_inifilename.bind('<Return>',self.getInputFile) ## enable file name to be set by [Enter] or [Return] self.input_inifilename.grid(row=0,column=1,sticky='EW') ## make input field streching with window Button(filesframe,text='…',command=(self.selectImportFile)).grid(row=0,column=2,sticky='W') ## opens dialog to choose file from ## input mask for data logger file # Label(filesframe, text='Data Logger File:').grid(row=1,sticky='E') self.input_logfilename = Entry(filesframe, width = 40) self.input_logfilename.bind('<Return>',self.setLogFile) ## enable file name to be set by [Enter] or [Return] self.input_logfilename.grid(row=1,column=1,sticky='EW') ## make input field streching with window Button(filesframe,text='…',command=(self.selectLoggerDataFile)).grid(row=1,column=2,sticky='W') ## opens dialog to choose file from ## enable daily log option in GUI, has no own action, will be regarded during log write self.checked_daily = IntVar() self.checkManageData=Checkbutton(filesframe, text='Create daily log file', variable=self.checked_daily ) self.checkManageData.grid(row=2,column=0,columnspan=3) Button(filesframe,text='⟲ Re-Read Configuration', command=(self.displaySettings)).grid(row=3,column=0,sticky='W') ## triggers re-read of the configuration file Button(filesframe,text='⤓ Save Current Configuration', command=(inout.writeExportFile)).grid(row=3,column=1,sticky='W') ## triggers re-read of the configuration file ## buttons for starting and stopping data retrieval from the addressed target # ## Button for starting communication and starting writing to logger file self.commButton = Button(controlframe,text='▶ Start Communication',bg='lightblue', command=self.startCommunication) self.commButton.grid(row=0,column=1,sticky='W') ## fields for configuring the data connection # Label(self.settingsframe, text='Communication Connection Settings', font='-weight bold').grid(columnspan=4, sticky='W') Label(self.settingsframe, text='Current Values').grid(row=1,column=1) Label(self.settingsframe, text='New Values').grid(row=1,column=2) Label(self.settingsframe, text='Target IP Address:').grid(row=2,column=0,sticky = 'E') Label(self.settingsframe, text='Port No.:').grid(row=3,column=0,sticky = 'E') Label(self.settingsframe, text='Modbus Unit ID:').grid(row=4,column=0,sticky = 'E') Label(self.settingsframe, text='Manufacturer:').grid(row=5,column=0,sticky = 'E') Label(self.settingsframe, text='Log Interval[s]:').grid(row=6,column=0,sticky = 'E') Button(self.settingsframe,text='⮴ Update Settings',bg='lightgreen',command=(self.updateCommSettings)).grid(row=7,column=2, sticky='W') ## frame for entering and displaying the data objects self.datasettingsframe = Frame(self.settingscanvas,bd=1,relief='groove') self.datasettingsframe.columnconfigure(3,weight=1) ## make description field fit the window self.datasettingsframe.grid(sticky = 'EW') ## table with data objects to display and the received data Label(self.datasettingsframe, text='Target Data', font='-weight bold').grid(columnspan=4, sticky='W') Label(self.datasettingsframe, text='Addr.').grid(row=1,column=0) Label(self.datasettingsframe, text='Type').grid(row=1,column=1) Label(self.datasettingsframe, text='Format').grid(row=1,column=2) Label(self.datasettingsframe, text='Description').grid(row=1,column=3) Label(self.datasettingsframe, text='Unit').grid(row=1,column=4) self.input_modaddress=Entry(self.datasettingsframe,width=7) self.input_modaddress.grid(row=2,column=0) self.input_moddatatype = StringVar() self.input_moddatatype.set(list(data.moddatatype.keys())[0])#[0]) self.choice_moddatatype=OptionMenu(self.datasettingsframe,self.input_moddatatype,*data.moddatatype) self.choice_moddatatype.grid(row=2,column=1) self.input_dataformat = StringVar() self.input_dataformat.set(None) self.choice_moddatatype=OptionMenu(self.datasettingsframe,self.input_dataformat,*data.dataformat) self.choice_moddatatype.grid(row=2,column=2) self.input_description=Entry(self.datasettingsframe,width=35) self.input_description.grid(row=2,column=3,sticky='ew') self.input_dataunit=Entry(self.datasettingsframe,width=5) self.input_dataunit.grid(row=2,column=4) Button(self.datasettingsframe,text='+',font='-weight bold',bg='lightyellow',command=(self.addNewDataset)).grid(row=2,column=6) ## checkbutton to enable manipulation of the entered data. # this is slow, therefore not enabled by default. Also it alters the display layout. self.checked_manage = IntVar() self.checkManageData=Checkbutton(self.datasettingsframe, text='Manage data sets', variable=self.checked_manage, command=self.displayDatasets, ) self.checkManageData.grid(row=3,column=0,columnspan=3) ## canvas for displaying monitored data self.datacanvas = Canvas(master,bd=1,bg="green",highlightthickness=0) self.datacanvas.pack(anchor='sw',side='top',expand=True,fill='both') ## frame that holds all data to display. the static data table and the polled data self.dataframe = Frame(self.datacanvas) self.dataframe.pack(side='left',expand=True,fill='both') ## frame for static data table self.datadisplayframe = Frame(self.dataframe,bd=1,relief='groove') #self.datadisplayframe = Frame(self.datacanvas,bd=1,relief='groove') self.datadisplayframe.pack(side='left', anchor='nw',expand=True,fill='both') ## frame for data from target self.targetdataframe = Frame(self.dataframe,bg='white',relief='groove',bd=1) self.targetdataframe.pack(side='left', anchor='nw',expand=True,fill='both') #self.targetdataframe.grid(column=1, row=0) ## add scrollbar for many data rows self.datascrollbar = Scrollbar(self.datacanvas, orient='vertical', command=self.datacanvas.yview) self.datascrollbar.pack(side='right',fill='y') #self.datascrollbar = Scrollbar(self.datacanvas, orient='vertical', command=self.datacanvas.yview) self.datacanvas.configure(yscrollcommand=self.datascrollbar.set) ## make data table fit in scrollable frame self.datacanvas.create_window((0,0), window=self.dataframe, anchor='nw',tags='dataframe') ## fill the datafields with the current settings self.displayCommSettings() self.displayDatasets() self.update_data_layout() ## function for updating the data view after adding content to make the scrollbar work correctly def update_data_layout(self): self.dataframe.update_idletasks() self.datacanvas.configure(scrollregion=self.datacanvas.bbox('all')) def displaySettings(self): ## read import file and update displayed data inout.readImportFile() self.displayCommSettings() self.displayDatasets() ## update logfile display self.input_logfilename.delete(0,END) self.input_logfilename.insert(0,data.logfilename) ## update displayed filename in entry field self.input_inifilename.delete(0,END) self.input_inifilename.insert(0,data.inifilename) def displayDatasets(self): ## display all currently available datasets for widget in self.datadisplayframe.winfo_children(): widget.destroy() if (self.checked_manage.get()): Label(self.datadisplayframe,text='Up').grid(row=0,column=0) Label(self.datadisplayframe,text='Down').grid(row=0,column=1) Label(self.datadisplayframe,text='Delete').grid(row=0,column=2) thisdata = '' ## make local variable known for thisdata in data.datasets: counter = data.datasets.index(thisdata) ## to keep track of the current row if (self.checked_manage.get()): ## add some buttons to change order of items and also to delete them if (counter > 1): ## first dataset cannot be moved up buttonUp=Button(self.datadisplayframe, text='↑', command=lambda i=counter:(self.moveDatasetUp(i))) buttonUp.grid(row=(counter),column = 0) if ((counter > 0) and (counter != (len(data.datasets)-1))): ## last dataset cannot be moved down buttonDown=Button(self.datadisplayframe, text='↓', command=lambda i=counter:(self.moveDatasetDown(i))) buttonDown.grid(row=(counter),column = 1) if (counter > 0): ## do not remove dataset [0] buttonDelete=Button(self.datadisplayframe, text='-', command=lambda i=counter:(self.deleteDataset(i))) buttonDelete.grid(row=(counter),column = 2) ## add the currently stored data for the dataset Label(self.datadisplayframe,width=3,text=counter).grid(row=(counter),column=3) Label(self.datadisplayframe,width=6,text=thisdata[0]).grid(row=(counter),column=4) Label(self.datadisplayframe,width=7,text=thisdata[1]).grid(row=(counter),column=5) Label(self.datadisplayframe,width=7,text=thisdata[2]).grid(row=(counter),column=6) Label(self.datadisplayframe,width=25,text=thisdata[3]).grid(row=(counter),column=7,sticky='ew') Label(self.datadisplayframe,width=6,text=thisdata[4]).grid(row=(counter),column=8) self.update_data_layout() ## reorder the datasets, move current dataset one up def moveDatasetUp(self,current_position): i = current_position data.datasets[i], data.datasets[(i-1)] = data.datasets[(i-1)], data.datasets[i] self.displayDatasets() ## reorder the datasets, move current dataset one down def moveDatasetDown(self,current_position): i = current_position data.datasets[i], data.datasets[(i+1)] = data.datasets[(i+1)], data.datasets[i] self.displayDatasets() ## reorder the datasets, delete the current dataset def deleteDataset(self,current_position): i = current_position del data.datasets[i] self.displayDatasets() def displayCommSettings(self): self.current_ipaddress = Label(self.settingsframe, text=data.ipaddress, bg='white') self.current_ipaddress.grid (row=2,column=1,sticky='EW') self.input_ipaddress = Entry(self.settingsframe, width=15, fg='blue') self.input_ipaddress.grid(row=2,column=2, sticky = 'W') # needs to be on a seperate line for variable to work self.input_ipaddress.bind('<Return>',self.updateCommSettings) ## enable the Entry to update without button click self.current_portno = Label(self.settingsframe, text=data.portno, bg='white') self.current_portno.grid (row=3,column=1,sticky='EW') self.input_portno = Entry(self.settingsframe, width=5, fg='blue') self.input_portno.grid(row=3,column=2, sticky = 'W') self.input_portno.bind('<Return>',self.updateCommSettings) ## update without button click self.current_modbusid = Label(self.settingsframe, text=data.modbusid, bg='white') self.current_modbusid.grid (row=4,column=1,sticky='EW') self.input_modbusid = Entry(self.settingsframe, width=5, fg='blue') self.input_modbusid.grid(row=4,column=2, sticky = 'W') self.input_modbusid.bind('<Return>',self.updateCommSettings) ## update without button click self.current_manufacturer = Label(self.settingsframe, text=data.manufacturer, bg='white') self.current_manufacturer.grid (row=5,column=1,sticky='EW') self.input_manufacturer = Entry(self.settingsframe, width=25, fg='blue') self.input_manufacturer.grid(row=5,column=2, sticky = 'W') self.input_manufacturer.bind('<Return>',self.updateCommSettings) ## update without button click self.current_loginterval = Label(self.settingsframe, text=data.loginterval, bg='white') self.current_loginterval.grid (row=6,column=1,sticky='EW') self.input_loginterval = Entry(self.settingsframe, width=3, fg='blue') self.input_loginterval.grid(row=6,column=2, sticky = 'W') self.input_loginterval.bind('<Return>',self.updateCommSettings) ## update without button click ## function for updating communication parameters with input sanitation # if no values are given in some fields the old values are preserved # def updateCommSettings(self,*args): #print('update Communication Settings:') if self.input_ipaddress.get() != '': thisipaddress = unicode(self.input_ipaddress.get()) ## test if the data seems to be a valid IP address try: self.ip_address(thisipaddress) data.ipaddress = unicode(self.input_ipaddress.get()) except: showerror('IP Address Error','the data you entered seems not to be a correct IP address') ## if valid ip address entered store it if self.input_portno.get() != '': ## test if the portnumber seems to be a valid value try: check_portno = int(self.input_portno.get()) if check_portno < 0: raise ValueError except ValueError: showerror('Port Number Error','the value you entered seems not to be a valid port number') return data.portno = int(self.input_portno.get()) if self.input_modbusid.get() != '': ## test if the modbus ID seems to be a valid value try: check_modbusid = int(self.input_portno.get()) if check_modbusid < 0: raise ValueError except ValueError: showerror('Port Number Error','the value you entered seems not to be a valid Modbus ID') return data.modbusid = int(self.input_modbusid.get()) if self.input_manufacturer.get() != '': data.manufacturer = (self.input_manufacturer.get()) if self.input_loginterval.get() != '': ## test if the logger intervall seems to be a valid value try: check_loginterval = int(self.input_loginterval.get()) if check_loginterval < 1: raise ValueError except ValueError: showerror('Logger Interval Error','the value you entered seems not to be a valid logger intervall') return data.loginterval = int(self.input_loginterval.get()) self.displayCommSettings() ## function for starting communication and changing button function and text # def startCommunication(self): inout.runCommunication() self.commButton.configure(text='⏹ Stop Communication',bg='red', command=(self.stopCommunication)) def stopCommunication(self): inout.stopCommunication() self.commButton.configure(text='▶ Start Communication',bg='lightblue', command=(self.startCommunication)) ## function for reading configuration file # def selectImportFile(self): data.inifilename = askopenfilename(title = 'Choose Configuration File',defaultextension='.ini',filetypes=[('Configuration file','*.ini'), ('All files','*.*')]) ## update displayed filename in entry field self.input_inifilename.delete(0,END) self.input_inifilename.insert(0,data.inifilename) self.displaySettings() ## function for checking for seemingly correct IP address input # def ip_address(self,address): valid = address.split('.') if len(valid) != 4: raise ValueError for element in valid: if not element.isdigit(): raise ValueError break i = int(element) if i < 0 or i > 255: raise ValueError return ## function for selecting configuration export file # def selectExportFile(self): data.inifilename = asksaveasfilename(initialfile = data.inifilename, title = 'Choose Configuration File', defaultextension='.ini', filetypes=[('Configuration file','*.ini'), ('All files','*.*')]) ## update displayed filename in entry field self.input_inifilename.delete(0,END) self.input_inifilename.insert(0,data.inifilename) inout.writeExportFile() ## function for choosing logger data file # def selectLoggerDataFile(self): data.logfilename = asksaveasfilename(initialfile = data.logfilename, title = 'Choose File for Logger Data', defaultextension='.csv',filetypes=[('CSV file','*.csv'), ('All files','*.*')]) self.input_logfilename.delete(0,END) self.input_logfilename.insert(0,data.logfilename) ## function for updating the current received data on display # def updateLoggerDisplay(self): thisdata = '' ## make variable data known ## delete old data for displayed in self.targetdataframe.winfo_children(): displayed.destroy() ## display new data Label(self.targetdataframe,text='Value').grid(row=0,column=0) for thisdata in data.datavector: ## send data to display table Label(self.targetdataframe,text=thisdata,bg='white').grid(column=0,sticky='e') ## function for setting program preferences (if needed) # def dataSettings(self): print('dataSettings') ## function for updating the configuration file # with the path entered into the text field # def getInputFile(self,event): data.inifilename = event.widget.get() ## function for updating the log file path # with the path entered into the entry field # def setLogFile(self,event): data.logfilename = event.widget.get() ## function adds dataset to the datasets list # also updates the displayed list # new datasets are not added to the config file # def addNewDataset(self): inout.addDataset([self.input_modaddress.get(), self.input_moddatatype.get(), self.input_dataformat.get(), self.input_description.get(), self.input_dataunit.get()]) self.displayDatasets() #print (data.datasets) ## function for displaying the about dialog # def aboutDialog(self): showinfo('About Python Modbus Monitor'\ ,'This is a program that acts as a modbus slave to receive data from modbus masters like SMA solar inverters. \nYou can choose the data to be received via the GUI and see the live data. \nYou can also call the programm from the command line with a configuration file given for the data to be retrieved. \nThe configuration file can be generated using the GUI command \"File\"→\"Export Configuration\"') ## function for closing the program window # def closeWindow(self): exit() ## create a data object data = Data() ## create an input output object inout = Inout() ## what to do on program exit atexit.register(inout.cleanOnExit) ## create main program window ## if we are in command line mode lets detect it gui_active = 0 if (arguments['--nogui'] == False): ## load graphical interface library from Tkinter import * from tkMessageBox import * from tkFileDialog import * try: ## if the program was called from command line without parameters window = Tk() ## create window container gui = Gui(window) gui_active = 1 if (arguments['--inifile'] != None): inout.checkImportFile() gui.displaySettings() mainloop() exit() ## if quitting from GUI do not proceed further down to command line handling except TclError: ## check if one of the required command line parameters is set if ((arguments['--inifile'] == None) and (arguments['--ip'] == None)): print 'Error. No graphical interface found. Try "python pymodmon.py -h" for help.' exit() ## else continue with command line execution ######## this section handles all command line logic ########################## ## read the configuration file if (arguments['--inifile'] != None): inout.checkImportFile() ## get log file name and try to access it if (arguments['--logfile'] != None): data.logfilename = str(arguments['--logfile']) inout.writeLoggerDataFile() ## initial write to file, tests for file ## get log interval value and check for valid value if (arguments['--loginterval'] != None): try: check_loginterval = int(arguments['--loginterval']) if check_loginterval < 1: raise ValueError except ValueError: print('Log interval error. The interval must be 1 or more.') exit() data.loginterval = int(arguments['--loginterval']) ## get log buffer size and check for valid value if (arguments['--logbuffer'] != None): try: check_logbuffer = int(arguments['--logbuffer']) if check_logbuffer < 1: raise ValueError except ValueError: print('Log buffer error. The log buffer must be 1 or more.') exit() data.logmaxbuffer = int(arguments['--logbuffer']) ## get all values for single-value reads ## all obligatory entries. missing entries will be caught by docopt. # only simple checks will be done, because if there are errors, communication will fail. if (arguments['--ip'] != None): ## just a check for flow logic, skipped when working with inifile data.ipaddress = str(arguments['--ip']) data.modbusid = int(arguments['--id']) data.port = int(arguments['--port']) ## because called from command line data.datasets has only one entry # we can just append and use same mechanics as in "normal" mode data.datasets.append( [int(arguments['--addr']), str(arguments['--type']), str(arguments['--format']), str(arguments['--descr']), str(arguments['--unit']) ] ) ## start polling data ## single poll first inout.runCommunication() ## if --single is set, exit immediately if (arguments['--single'] == True): inout.stopCommunication() print 'single run' exit()