Newer
Older
SCADA / modbus / misc / pymodmon.py
root on 8 May 2022 46 KB playing with modbus day #1
  1. # coding=UTF-8
  2.  
  3. ## @package pymodmon
  4. # Python Modbus Monitor
  5. # a small program that uses the pymodbus package to retrieve and
  6. # display modbus slave data.
  7. # requires: Python 2.7, pymodbus, docopt
  8. #
  9. # Date created: 2016-05-04
  10. # Author: André Schieleit
  11.  
  12. ## help message to display by docopt (and parsed by docopt for command line arguments)
  13. '''Python Modbus Monitor.
  14.  
  15. Usage:
  16. pymodmon.py
  17. pymodmon.py [-h|--help]
  18. pymodmon.py [--version]
  19. pymodmon.py -i <file>|--inifile=<file> [-l <file>|--logfile=<file>] [-L <sec>|--loginterval=<sec>] [-B <buf>|--logbuffer=<buf>] [-S|--single] [--nogui] [-D|--daily-log]
  20. 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>]
  21.  
  22. Options:
  23. no options given in a xterm will open the TK interface
  24. -h, --help Show this screen
  25. --version Show version
  26. -i, --inifile=<file> Uses the given file as input for communication and
  27. log file settings and channel configuration
  28. -l, --logfile=<file> Uses the given file as output for the retrieved data.
  29. The data will be formatted in csv.
  30. Existing files will be appended.
  31. --ip=<IP-address> Use this as the IP address of the communication target
  32. --port=<port> Port of the communication target
  33. --id=<id> Modbus ID of the communication target
  34. --addr=<adr> Address of the modbus register to read
  35. --type=<TYPE> Data type of the retrieved data at a given address.
  36. Allowed types: U64, U32, U16, S32, S16, STR32
  37. --format=<FORM> Format of the retrieved data.
  38. Allowed formats: RAW, UTF8, FIX0, FIX1, FIX2, FIX3
  39. --descr=<descr> Description for the retrieved data.
  40. e.g. --descr="device name"
  41. --unit=<unit> Unit of the retrieved data. e.g. --unit="V"
  42. -G, --nogui Explicitly run without gui even when available
  43. -S, --single Do only one read cycle instead of continuous reading.
  44. -L, --loginterval=<sec> Read data every xx seconds. [defaul value: 5]
  45. -B, --logbuffer=<buf> Read xx datasets before writing to disk.
  46. Useful to prevent wearout on solid state devices.
  47. [default value: 50]
  48. -D, --daily-log Writes a log file for each day. At 00:00:00 system
  49. time a new log file will be started. A given log file
  50. name will be appended with the current date with
  51. "%Y-%m-%d" format.
  52. '''
  53.  
  54. ## use docopt for command line parsing and displaying help message
  55. try:
  56. import docopt
  57. except ImportError:
  58. try: ## for command line showerror does not work
  59. showerror('Import Error','docopt package was not found on your system.\nPlease install it using the command:\
  60. \n"pip install docopt"')
  61. except:
  62. print ('Import errror. docopt package was not found on your system. Please install it using the command: "pip install docopt"')
  63. from docopt import docopt
  64. if __name__ == '__main__':
  65. arguments = docopt(__doc__, version='PyModMon 1.0')
  66.  
  67. ## use pymodbus for the Modbus communication
  68. try:
  69. from pymodbus import *
  70. except ImportError:
  71. try: ## for command line showerror does not work
  72. showerror('Import Error','pymodbus package was not found on your system.\nPlease install it using the command:\
  73. \n"pip install pymodbus"')
  74. except:
  75. print ('Import errror. pymodbus package was not found on your system. Please install it using the command: "pip install pymodbus"')
  76.  
  77. ## enable execution of functions on program exit
  78. import atexit
  79.  
  80. ## enable timed execution of the data polling
  81. from threading import Timer
  82.  
  83. ## enable file access
  84. import os
  85.  
  86. ## class for all data related things
  87. #
  88. class Data(object):
  89. ## set default values and allowed input values
  90. def __init__(self):
  91. self.inifilename = None
  92. self.logfilename = None
  93. self.logmaxbuffer = 50 ## how many records will be buffered before writing to file
  94. self.ipaddress = '10.0.0.42' ## address of the communication target
  95. self.portno = 502 ## port number of the target
  96. self.modbusid = 3 ## bus ID of the target
  97. self.manufacturer = 'Default Manufacturer' ## arbitrary string for user conveniance
  98. self.loginterval = 5 ## how often should data be pulled from target in seconds
  99. self.moddatatype = { ## allowed data types, sent from target
  100. 'S32':2,
  101. 'U32':2,
  102. 'U64':4,
  103. 'STR32':16,
  104. 'S16':1,
  105. 'U16':1
  106. }
  107.  
  108. self.dataformat = ['ENUM','UTF8','FIX3','FIX2','FIX1','FIX0','RAW'] ## data format from target
  109.  
  110. ## table of data to be pulled from target
  111. self.datasets = [['address','type','format','description','unit','value']]
  112.  
  113. self.datavector = [] ## holds the polled data from target
  114. self.databuffer = [] ## holds the datavectors before writing to disk
  115. self.datawritebuffer = [] ## holds a copy of databuffer for actual writing to disk
  116.  
  117. ## class that contains all IO specifics
  118. class Inout:
  119. ## some values to check against when receiving data from target
  120. # these values are read when there is not acutal value from the target available.
  121. # they are the equivalent to None
  122. MIN_SIGNED = -2147483648
  123. MAX_UNSIGNED = 4294967295
  124.  
  125. ## function for testing the per command line specified configuration file
  126. def checkImportFile(self):
  127. ## does the file exist?
  128. try:
  129. inifile = open(str(arguments['--inifile']),'r').close()
  130. data.inifilename = str(arguments['--inifile'])
  131. except:
  132. ## if we have a GUI display an error dialog
  133. try:
  134. showerror('Import Error','The specified configuration file was not found.')
  135. return
  136. except: ## if no GUI display error and exit
  137. print('Configuration file error. A file with that name seems not to exist, please check.')
  138. exit()
  139. try:
  140. inout.readImportFile()
  141. except:
  142. try:
  143. showerror('Import Error','Could not read the configuration file. Please check file path and/or file.')
  144. return
  145. except:
  146. print 'Could not read configuration file. Please check file path and/or file.'
  147. exit()
  148.  
  149. ## function for acually reading input configuration file
  150. def readImportFile(self):
  151. ## read config data from file
  152. import ConfigParser
  153. Config = ConfigParser.SafeConfigParser()
  154. ## read the config file
  155. Config.read(data.inifilename)
  156. data.ipaddress = Config.get('CommSettings','IP address')
  157. data.portno = int(Config.get('CommSettings','port number'))
  158. data.modbusid = int(Config.get('CommSettings','Modbus ID'))
  159. data.manufacturer = Config.get('CommSettings','manufacturer')
  160. data.loginterval = int(Config.get('CommSettings','logger interval'))
  161. try: ## logfilename may be empty. if so data will printed to terminal
  162. data.logfilename = Config.get('FileSettings','log file')
  163. except:
  164. data.logfilename = None
  165. data.logmaxbuffer = int(Config.get('FileSettings','log buffer'))
  166. data.datasets = eval(Config.get('TargetDataSettings','data table'))
  167.  
  168. ## function for actually writing configuration data
  169. #
  170. def writeExportFile(self):
  171. ## use ini file capabilities
  172. import ConfigParser
  173. Config = ConfigParser.ConfigParser()
  174.  
  175. ## if the dialog was closed with no file selected ('cancel') just return
  176. if (data.inifilename == None):
  177. try: ## if running in command line no window can be displayed
  178. showerror('Configuration File Error','no file name given, please check.')
  179. except:
  180. print('Configuration file error, no file name given, please check.')
  181. return
  182. ## write the data to the selected config file
  183. try:
  184. inifile = open(data.inifilename,'w')
  185. except:
  186. try: ## if running in command line no window can be displayed
  187. showerror('Configuration File Error','a file with that name seems not to exist, please check.')
  188. except:
  189. print('Configuration file error, a file with that name seems not to exist, please check.')
  190. gui.selectExportFile()
  191. return
  192.  
  193. ## format the file structure
  194. Config.add_section('CommSettings')
  195. Config.set('CommSettings','IP address',data.ipaddress)
  196. Config.set('CommSettings','port number',data.portno)
  197. Config.set('CommSettings','Modbus ID',data.modbusid)
  198. Config.set('CommSettings','manufacturer',data.manufacturer)
  199. Config.set('CommSettings','logger interval',data.loginterval)
  200. Config.add_section('FileSettings')
  201. Config.set('FileSettings','log file',data.logfilename)
  202. Config.set('FileSettings','log buffer',data.logmaxbuffer)
  203. Config.add_section('TargetDataSettings')
  204. Config.set('TargetDataSettings','data table',data.datasets)
  205.  
  206. Config.write(inifile)
  207. inifile.close()
  208.  
  209. ## function for writing to log file
  210. # checks if it is an existing file with data in it and will append then
  211. # this function should only be called in intervals writing data in bulk (e.g. every 5 minutes)
  212. # to prevent wearout on solid state disks like SD CARDs
  213. #
  214. def writeLoggerDataFile(self):
  215. import csv ## for writing in csv format
  216. import datetime ## for daily log option
  217.  
  218. if (data.logfilename == None): ## when no filename is given, print data to terminal
  219. if len(data.datawritebuffer) > 0: ## if the buffer has data write this to terminal
  220. print (data.datawritebuffer)
  221. data.datawritebuffer = [] ## empty buffer
  222. else: ## we asume that this was called outside the poll loop with buffer size not reached
  223. print(data.databuffer)
  224. if (len(data.databuffer) == 1): ## if only one address was provided via command line
  225. print data.databuffer[0][0],data.datasets[1][3],data.databuffer[0][1],data.datasets[1][4]
  226. data.databuffer = [] ## empty buffer
  227. return
  228.  
  229. thislogfile = data.logfilename ## store filename locally for daily option
  230. thisdate = str(datetime.date.today())
  231. gui_checked_daily = 0
  232.  
  233. ## is GUI available or is command line mode active
  234. if (gui_active):
  235. gui_checked_daily = int(gui.checked_daily.get())
  236.  
  237. ## if daily option is active
  238. if ( (arguments['--daily-log'] == True) or (gui_checked_daily) ):
  239. ## assumption: the logfile has a file extension
  240. logfileparts = thislogfile.rpartition('.')
  241.  
  242. ## if there is no file extension we will append the current date to the name
  243. if (logfileparts[0] == ''):
  244. ## re-order logfileparts
  245. thislogfile = logfileparts[2]+'_'+thisdate
  246. else:
  247. ## format the new logfilename with current date included
  248. thislogfile = logfileparts[0]+'_'+thisdate+logfileparts[1]+logfileparts[2]
  249.  
  250. ## try to open the file. if it does not exist, create it on the way
  251. try:
  252. open(thislogfile, 'a').close()
  253. except:
  254. try: ## if running in command line no window can be displayed
  255. showerror('Log File Error','file cannot be accessed, please check.')
  256. except:
  257. print('Log file error. File cannot be accessed, please check.')
  258. return
  259.  
  260. ## check if the file is empty, if so write the header information to the file
  261. if os.stat(thislogfile).st_size==0:
  262. with open(thislogfile,'ab') as logfile:
  263. logwriter = csv.writer(logfile, quoting=csv.QUOTE_ALL)
  264. ## ensure UTF8 encoding while writing
  265. ## print out what data is contained and whats its format
  266. for thisrow in data.datasets:
  267. thisrow = [s.encode('utf-8') for s in thisrow]
  268. logwriter.writerows([thisrow])
  269.  
  270. logfile.write('-'*50+'\n') ## write a separator
  271. ## format the column headers
  272. columnheader = 'time'
  273. for thisrow in data.datasets[1:]: ## omit first row containing 'address'
  274. ## ensure UTF8 encoding while writing, since this is the column heading
  275. # no problem with converting to string even if stored as int
  276. thisrow = [s.encode('utf-8') for s in thisrow]
  277. ## use description field for columnheader if filled
  278. if (thisrow[3] != ''):
  279. thisdescription = ','+str(thisrow[3])
  280. ## if a unit is entered add it after the description
  281. if (thisrow[4] != ''):
  282. thisdescription += ' ('+str(thisrow[4])+')'
  283. columnheader += thisdescription
  284. else: ## no description, use address as header
  285. columnheader += ', '+str(thisrow[0])
  286.  
  287. columnheader += '\n' ## line break before data rows
  288. logfile.write(columnheader)
  289.  
  290. ## if the file is not empty we assume an append write to the file
  291. if len(data.datawritebuffer) > 0: ## if the buffer has data write this to disk
  292. with open(thislogfile,'ab') as logfile:
  293. logwriter = csv.writer(logfile)
  294. logwriter.writerows(data.datawritebuffer)
  295. data.datawritebuffer = [] ## empty buffer
  296. else: ## we asume that this was called outside the poll loop with buffer size not reached
  297. with open(thislogfile,'ab') as logfile:
  298. logwriter = csv.writer(logfile)
  299. logwriter.writerows(data.databuffer)
  300. data.databuffer = [] ## empty buffer
  301.  
  302. ## function for starting communication with target
  303. #
  304. def runCommunication(self):
  305. from pymodbus.client.sync import ModbusTcpClient as ModbusClient
  306.  
  307. self.client = ModbusClient(host=data.ipaddress, port=data.portno)
  308. try:
  309. self.client.connect()
  310. except:
  311. showerror('Modbus Connection Error','could not connect to target. Check your settings, please.')
  312.  
  313. self.pollTargetData()
  314.  
  315. self.client.close()
  316. ## lambda: is required to not spawn hundreds of threads but only one that calls itself
  317. self.commtimer = Timer(data.loginterval, lambda: self.runCommunication())
  318. self.commtimer.start() ## needs to be a separate command else the timer is not cancel-able
  319.  
  320. def stopCommunication(self):
  321. #print ('Stopped Communication')
  322. self.commtimer.cancel()
  323. ## flush data buffer to disk
  324. self.writeLoggerDataFile()
  325.  
  326. ## function for polling data from the target and triggering writing to log file if set
  327. #
  328. def pollTargetData(self):
  329. from pymodbus.payload import BinaryPayloadDecoder
  330. from pymodbus.constants import Endian
  331. import datetime
  332.  
  333. data.datavector = [] ## empty datavector for current values
  334.  
  335. ## request each register from datasets, omit first row which contains only column headers
  336. for thisrow in data.datasets[1:]:
  337. ## if the connection is somehow not possible (e.g. target not responding)
  338. # show a error message instead of excepting and stopping
  339. try:
  340. received = self.client.read_input_registers(address = int(thisrow[0]),
  341. count = data.moddatatype[thisrow[1]],
  342. unit = data.modbusid)
  343. except:
  344. thisdate = str(datetime.datetime.now()).partition('.')[0]
  345. thiserrormessage = thisdate + ': Connection not possible. Check settings or connection.'
  346. if (gui_active):
  347. showerror('Connection Error',thiserrormessage)
  348. return ## prevent further execution of this function
  349. else:
  350. print thiserrormessage
  351. return ## prevent further execution of this function
  352.  
  353. message = BinaryPayloadDecoder.fromRegisters(received.registers, byteorder=Endian.Big, wordorder=Endian.Big)
  354. ## provide the correct result depending on the defined datatype
  355. if thisrow[1] == 'S32':
  356. interpreted = message.decode_32bit_int()
  357. elif thisrow[1] == 'U32':
  358. interpreted = message.decode_32bit_uint()
  359. elif thisrow[1] == 'U64':
  360. interpreted = message.decode_64bit_uint()
  361. elif thisrow[1] == 'STR32':
  362. interpreted = message.decode_string(32)
  363. elif thisrow[1] == 'S16':
  364. interpreted = message.decode_16bit_int()
  365. elif thisrow[1] == 'U16':
  366. interpreted = message.decode_16bit_uint()
  367. else: ## if no data type is defined do raw interpretation of the delivered data
  368. interpreted = message.decode_16bit_uint()
  369.  
  370. ## check for "None" data before doing anything else
  371. if ((interpreted == self.MIN_SIGNED) or (interpreted == self.MAX_UNSIGNED)):
  372. displaydata = None
  373. else:
  374. ## put the data with correct formatting into the data table
  375. if thisrow[2] == 'FIX3':
  376. displaydata = float(interpreted) / 1000
  377. elif thisrow[2] == 'FIX2':
  378. displaydata = float(interpreted) / 100
  379. elif thisrow[2] == 'FIX1':
  380. displaydata = float(interpreted) / 10
  381. else:
  382. displaydata = interpreted
  383.  
  384. ## save _scaled_ data in datavector for further handling
  385. data.datavector.append(displaydata)
  386.  
  387. ## display collected data
  388. if (gui_active == 1):
  389. gui.updateLoggerDisplay()
  390.  
  391. ## for logging purposes we need a time stamp first
  392. stampedvector = []
  393. ## we don't need the microseconds of the date return value, so we strip it
  394. stampedvector.append(str(datetime.datetime.now()).partition('.')[0])
  395. stampedvector += data.datavector
  396. data.databuffer.append(stampedvector)
  397. #print data.databuffer
  398. ## is the buffer large enough to be written to file system?
  399. if (len(data.databuffer) >= data.logmaxbuffer):
  400. ## ensure that the data to write will not be altered by faster poll cycles
  401. data.datawritebuffer = data.databuffer
  402. data.databuffer = [] ## empty the buffer
  403. self.writeLoggerDataFile() ## call write routine to save data on disk
  404.  
  405. ## function adds dataset to the datasets list
  406. # also updates the displayed list
  407. # new datasets are not added to the config file
  408. #
  409. def addDataset(self,inputdata):
  410. data.datasets.append(inputdata)
  411. print 'Current datasets: ',(data.datasets)
  412.  
  413. ## function for saving program state at program exit
  414. #
  415. def cleanOnExit(self):
  416. try: ## stop data logging on exit, catch a possible exception, when communication is not running
  417. self.stopCommunication()
  418. except:
  419. print ''
  420.  
  421. ## if data is available, write polled data from buffer to disk
  422. if len(data.databuffer):
  423. self.writeLoggerDataFile()
  424. print 'PyModMon has exited cleanly.'
  425.  
  426. ## function for printing the current configuration settings
  427. # only used for debug purpose
  428. #
  429. def printConfig(self):
  430. counter = 0
  431. for data in data.datasets:
  432. print('Datasets in List:', counter, data)
  433. counter += 1
  434.  
  435. ## class that contains all GUI specifics
  436. #
  437. class Gui:
  438. def __init__(self,master):
  439.  
  440. ## configure app window
  441. master.title('Python Modbus Monitor')
  442. master.minsize(width=550, height=450)
  443. master.geometry("550x550") ## scale window a bit bigger for more data lines
  444. self.settingscanvas = Canvas(master,bg="yellow",highlightthickness=0)
  445. self.settingscanvas.pack(side='top',anchor='nw',expand=False,fill='x')
  446.  
  447. ## make the contents of settingscanvas fit the window width
  448. Grid.columnconfigure(self.settingscanvas,0,weight = 1)
  449.  
  450. ## create window containers
  451.  
  452. ## frame for the config file and data logger file display
  453. filesframe = Frame(self.settingscanvas,bd=1,relief='groove')
  454. filesframe.columnconfigure(1,weight=1) ## set 2nd column to be auto-stretched when window is resized
  455. filesframe.grid(sticky = 'EW')
  456.  
  457. ## frame for the settings of the communication parameters
  458. self.settingsframe = Frame(self.settingscanvas,bd=1,relief='groove')
  459. self.settingsframe.grid(sticky = 'EW')
  460.  
  461. ## frame for the controls for starting and stopping configuration
  462. controlframe = Frame(self.settingscanvas,bd=1,relief='groove')
  463. controlframe.grid(sticky = 'EW')
  464.  
  465. ## create Menu
  466. menubar = Menu(master)
  467. filemenu = Menu(menubar, tearoff=0)
  468. filemenu.add_command(label='Import Configuration File…',command=self.selectImportFile)
  469. filemenu.add_command(label='Export Configuration File…',command=self.selectExportFile)
  470. filemenu.add_command(label='Set Logger Data File…',command=self.selectLoggerDataFile)
  471. filemenu.add_command(label='Save Current Configuration',command=inout.writeExportFile)
  472. filemenu.add_command(label='Exit',command=self.closeWindow)
  473.  
  474. toolmenu = Menu(menubar, tearoff=0)
  475. toolmenu.add_command(label='Data Settings…',command=self.dataSettings)
  476. toolmenu.add_command(label='Print Config Data',command=inout.printConfig)
  477.  
  478. helpmenu = Menu(menubar, tearoff=0)
  479. helpmenu.add_command(label='About…',command=self.aboutDialog)
  480.  
  481. menubar.add_cascade(label='File', menu=filemenu)
  482. menubar.add_cascade(label='Tools', menu=toolmenu)
  483. menubar.add_cascade(label='Help', menu=helpmenu)
  484. master.config(menu=menubar)
  485.  
  486. ## add GUI elements
  487.  
  488. ## input mask for configuration file
  489. #
  490. Label(filesframe, text='Configuration File:').grid(row=0,sticky='E')
  491.  
  492. self.input_inifilename = Entry(filesframe, width = 40)
  493. self.input_inifilename.bind('<Return>',self.getInputFile) ## enable file name to be set by [Enter] or [Return]
  494. self.input_inifilename.grid(row=0,column=1,sticky='EW') ## make input field streching with window
  495.  
  496. Button(filesframe,text='…',command=(self.selectImportFile)).grid(row=0,column=2,sticky='W') ## opens dialog to choose file from
  497.  
  498. ## input mask for data logger file
  499. #
  500. Label(filesframe, text='Data Logger File:').grid(row=1,sticky='E')
  501.  
  502. self.input_logfilename = Entry(filesframe, width = 40)
  503. self.input_logfilename.bind('<Return>',self.setLogFile) ## enable file name to be set by [Enter] or [Return]
  504. self.input_logfilename.grid(row=1,column=1,sticky='EW') ## make input field streching with window
  505.  
  506. Button(filesframe,text='…',command=(self.selectLoggerDataFile)).grid(row=1,column=2,sticky='W') ## opens dialog to choose file from
  507.  
  508. ## enable daily log option in GUI, has no own action, will be regarded during log write
  509. self.checked_daily = IntVar()
  510. self.checkManageData=Checkbutton(filesframe,
  511. text='Create daily log file',
  512. variable=self.checked_daily
  513. )
  514. self.checkManageData.grid(row=2,column=0,columnspan=3)
  515.  
  516. Button(filesframe,text='⟲ Re-Read Configuration', command=(self.displaySettings)).grid(row=3,column=0,sticky='W') ## triggers re-read of the configuration file
  517. Button(filesframe,text='⤓ Save Current Configuration', command=(inout.writeExportFile)).grid(row=3,column=1,sticky='W') ## triggers re-read of the configuration file
  518.  
  519. ## buttons for starting and stopping data retrieval from the addressed target
  520. #
  521.  
  522. ## Button for starting communication and starting writing to logger file
  523. self.commButton = Button(controlframe,text='▶ Start Communication',bg='lightblue', command=self.startCommunication)
  524. self.commButton.grid(row=0,column=1,sticky='W')
  525.  
  526. ## fields for configuring the data connection
  527. #
  528. Label(self.settingsframe, text='Communication Connection Settings', font='-weight bold').grid(columnspan=4, sticky='W')
  529. Label(self.settingsframe, text='Current Values').grid(row=1,column=1)
  530. Label(self.settingsframe, text='New Values').grid(row=1,column=2)
  531.  
  532. Label(self.settingsframe, text='Target IP Address:').grid(row=2,column=0,sticky = 'E')
  533. Label(self.settingsframe, text='Port No.:').grid(row=3,column=0,sticky = 'E')
  534. Label(self.settingsframe, text='Modbus Unit ID:').grid(row=4,column=0,sticky = 'E')
  535. Label(self.settingsframe, text='Manufacturer:').grid(row=5,column=0,sticky = 'E')
  536. Label(self.settingsframe, text='Log Interval[s]:').grid(row=6,column=0,sticky = 'E')
  537. Button(self.settingsframe,text='⮴ Update Settings',bg='lightgreen',command=(self.updateCommSettings)).grid(row=7,column=2, sticky='W')
  538.  
  539. ## frame for entering and displaying the data objects
  540. self.datasettingsframe = Frame(self.settingscanvas,bd=1,relief='groove')
  541. self.datasettingsframe.columnconfigure(3,weight=1) ## make description field fit the window
  542. self.datasettingsframe.grid(sticky = 'EW')
  543.  
  544. ## table with data objects to display and the received data
  545. Label(self.datasettingsframe, text='Target Data', font='-weight bold').grid(columnspan=4, sticky='W')
  546. Label(self.datasettingsframe, text='Addr.').grid(row=1,column=0)
  547. Label(self.datasettingsframe, text='Type').grid(row=1,column=1)
  548. Label(self.datasettingsframe, text='Format').grid(row=1,column=2)
  549. Label(self.datasettingsframe, text='Description').grid(row=1,column=3)
  550. Label(self.datasettingsframe, text='Unit').grid(row=1,column=4)
  551. self.input_modaddress=Entry(self.datasettingsframe,width=7)
  552. self.input_modaddress.grid(row=2,column=0)
  553.  
  554. self.input_moddatatype = StringVar()
  555. self.input_moddatatype.set(list(data.moddatatype.keys())[0])#[0])
  556. self.choice_moddatatype=OptionMenu(self.datasettingsframe,self.input_moddatatype,*data.moddatatype)
  557. self.choice_moddatatype.grid(row=2,column=1)
  558.  
  559. self.input_dataformat = StringVar()
  560. self.input_dataformat.set(None)
  561. self.choice_moddatatype=OptionMenu(self.datasettingsframe,self.input_dataformat,*data.dataformat)
  562. self.choice_moddatatype.grid(row=2,column=2)
  563.  
  564. self.input_description=Entry(self.datasettingsframe,width=35)
  565. self.input_description.grid(row=2,column=3,sticky='ew')
  566.  
  567. self.input_dataunit=Entry(self.datasettingsframe,width=5)
  568. self.input_dataunit.grid(row=2,column=4)
  569.  
  570. Button(self.datasettingsframe,text='+',font='-weight bold',bg='lightyellow',command=(self.addNewDataset)).grid(row=2,column=6)
  571.  
  572. ## checkbutton to enable manipulation of the entered data.
  573. # this is slow, therefore not enabled by default. Also it alters the display layout.
  574. self.checked_manage = IntVar()
  575. self.checkManageData=Checkbutton(self.datasettingsframe,
  576. text='Manage data sets',
  577. variable=self.checked_manage,
  578. command=self.displayDatasets,
  579. )
  580. self.checkManageData.grid(row=3,column=0,columnspan=3)
  581.  
  582. ## canvas for displaying monitored data
  583. self.datacanvas = Canvas(master,bd=1,bg="green",highlightthickness=0)
  584. self.datacanvas.pack(anchor='sw',side='top',expand=True,fill='both')
  585. ## frame that holds all data to display. the static data table and the polled data
  586. self.dataframe = Frame(self.datacanvas)
  587. self.dataframe.pack(side='left',expand=True,fill='both')
  588. ## frame for static data table
  589. self.datadisplayframe = Frame(self.dataframe,bd=1,relief='groove')
  590. #self.datadisplayframe = Frame(self.datacanvas,bd=1,relief='groove')
  591. self.datadisplayframe.pack(side='left', anchor='nw',expand=True,fill='both')
  592. ## frame for data from target
  593. self.targetdataframe = Frame(self.dataframe,bg='white',relief='groove',bd=1)
  594. self.targetdataframe.pack(side='left', anchor='nw',expand=True,fill='both')
  595. #self.targetdataframe.grid(column=1, row=0)
  596. ## add scrollbar for many data rows
  597. self.datascrollbar = Scrollbar(self.datacanvas, orient='vertical', command=self.datacanvas.yview)
  598. self.datascrollbar.pack(side='right',fill='y')
  599. #self.datascrollbar = Scrollbar(self.datacanvas, orient='vertical', command=self.datacanvas.yview)
  600. self.datacanvas.configure(yscrollcommand=self.datascrollbar.set)
  601.  
  602. ## make data table fit in scrollable frame
  603. self.datacanvas.create_window((0,0), window=self.dataframe, anchor='nw',tags='dataframe')
  604.  
  605. ## fill the datafields with the current settings
  606. self.displayCommSettings()
  607. self.displayDatasets()
  608.  
  609. self.update_data_layout()
  610.  
  611. ## function for updating the data view after adding content to make the scrollbar work correctly
  612. def update_data_layout(self):
  613. self.dataframe.update_idletasks()
  614. self.datacanvas.configure(scrollregion=self.datacanvas.bbox('all'))
  615.  
  616.  
  617. def displaySettings(self):
  618. ## read import file and update displayed data
  619. inout.readImportFile()
  620. self.displayCommSettings()
  621. self.displayDatasets()
  622.  
  623. ## update logfile display
  624. self.input_logfilename.delete(0,END)
  625. self.input_logfilename.insert(0,data.logfilename)
  626.  
  627. ## update displayed filename in entry field
  628. self.input_inifilename.delete(0,END)
  629. self.input_inifilename.insert(0,data.inifilename)
  630.  
  631. def displayDatasets(self):
  632. ## display all currently available datasets
  633. for widget in self.datadisplayframe.winfo_children():
  634. widget.destroy()
  635.  
  636. if (self.checked_manage.get()):
  637. Label(self.datadisplayframe,text='Up').grid(row=0,column=0)
  638. Label(self.datadisplayframe,text='Down').grid(row=0,column=1)
  639. Label(self.datadisplayframe,text='Delete').grid(row=0,column=2)
  640.  
  641. thisdata = '' ## make local variable known
  642. for thisdata in data.datasets:
  643. counter = data.datasets.index(thisdata) ## to keep track of the current row
  644. if (self.checked_manage.get()):
  645. ## add some buttons to change order of items and also to delete them
  646. if (counter > 1): ## first dataset cannot be moved up
  647. buttonUp=Button(self.datadisplayframe,
  648. text='↑',
  649. command=lambda i=counter:(self.moveDatasetUp(i)))
  650. buttonUp.grid(row=(counter),column = 0)
  651. if ((counter > 0) and (counter != (len(data.datasets)-1))): ## last dataset cannot be moved down
  652. buttonDown=Button(self.datadisplayframe,
  653. text='↓',
  654. command=lambda i=counter:(self.moveDatasetDown(i)))
  655. buttonDown.grid(row=(counter),column = 1)
  656. if (counter > 0): ## do not remove dataset [0]
  657. buttonDelete=Button(self.datadisplayframe,
  658. text='-',
  659. command=lambda i=counter:(self.deleteDataset(i)))
  660. buttonDelete.grid(row=(counter),column = 2)
  661.  
  662. ## add the currently stored data for the dataset
  663. Label(self.datadisplayframe,width=3,text=counter).grid(row=(counter),column=3)
  664. Label(self.datadisplayframe,width=6,text=thisdata[0]).grid(row=(counter),column=4)
  665. Label(self.datadisplayframe,width=7,text=thisdata[1]).grid(row=(counter),column=5)
  666. Label(self.datadisplayframe,width=7,text=thisdata[2]).grid(row=(counter),column=6)
  667. Label(self.datadisplayframe,width=25,text=thisdata[3]).grid(row=(counter),column=7,sticky='ew')
  668. Label(self.datadisplayframe,width=6,text=thisdata[4]).grid(row=(counter),column=8)
  669.  
  670. self.update_data_layout()
  671.  
  672. ## reorder the datasets, move current dataset one up
  673. def moveDatasetUp(self,current_position):
  674. i = current_position
  675. data.datasets[i], data.datasets[(i-1)] = data.datasets[(i-1)], data.datasets[i]
  676. self.displayDatasets()
  677.  
  678. ## reorder the datasets, move current dataset one down
  679. def moveDatasetDown(self,current_position):
  680. i = current_position
  681. data.datasets[i], data.datasets[(i+1)] = data.datasets[(i+1)], data.datasets[i]
  682. self.displayDatasets()
  683.  
  684. ## reorder the datasets, delete the current dataset
  685. def deleteDataset(self,current_position):
  686. i = current_position
  687. del data.datasets[i]
  688. self.displayDatasets()
  689.  
  690. def displayCommSettings(self):
  691. self.current_ipaddress = Label(self.settingsframe, text=data.ipaddress, bg='white')
  692. self.current_ipaddress.grid (row=2,column=1,sticky='EW')
  693. self.input_ipaddress = Entry(self.settingsframe, width=15, fg='blue')
  694. self.input_ipaddress.grid(row=2,column=2, sticky = 'W') # needs to be on a seperate line for variable to work
  695. self.input_ipaddress.bind('<Return>',self.updateCommSettings) ## enable the Entry to update without button click
  696.  
  697. self.current_portno = Label(self.settingsframe, text=data.portno, bg='white')
  698. self.current_portno.grid (row=3,column=1,sticky='EW')
  699. self.input_portno = Entry(self.settingsframe, width=5, fg='blue')
  700. self.input_portno.grid(row=3,column=2, sticky = 'W')
  701. self.input_portno.bind('<Return>',self.updateCommSettings) ## update without button click
  702.  
  703. self.current_modbusid = Label(self.settingsframe, text=data.modbusid, bg='white')
  704. self.current_modbusid.grid (row=4,column=1,sticky='EW')
  705. self.input_modbusid = Entry(self.settingsframe, width=5, fg='blue')
  706. self.input_modbusid.grid(row=4,column=2, sticky = 'W')
  707. self.input_modbusid.bind('<Return>',self.updateCommSettings) ## update without button click
  708.  
  709. self.current_manufacturer = Label(self.settingsframe, text=data.manufacturer, bg='white')
  710. self.current_manufacturer.grid (row=5,column=1,sticky='EW')
  711. self.input_manufacturer = Entry(self.settingsframe, width=25, fg='blue')
  712. self.input_manufacturer.grid(row=5,column=2, sticky = 'W')
  713. self.input_manufacturer.bind('<Return>',self.updateCommSettings) ## update without button click
  714.  
  715. self.current_loginterval = Label(self.settingsframe, text=data.loginterval, bg='white')
  716. self.current_loginterval.grid (row=6,column=1,sticky='EW')
  717. self.input_loginterval = Entry(self.settingsframe, width=3, fg='blue')
  718. self.input_loginterval.grid(row=6,column=2, sticky = 'W')
  719. self.input_loginterval.bind('<Return>',self.updateCommSettings) ## update without button click
  720.  
  721. ## function for updating communication parameters with input sanitation
  722. # if no values are given in some fields the old values are preserved
  723. #
  724. def updateCommSettings(self,*args):
  725.  
  726. #print('update Communication Settings:')
  727. if self.input_ipaddress.get() != '':
  728. thisipaddress = unicode(self.input_ipaddress.get())
  729. ## test if the data seems to be a valid IP address
  730. try:
  731. self.ip_address(thisipaddress)
  732. data.ipaddress = unicode(self.input_ipaddress.get())
  733. except:
  734. showerror('IP Address Error','the data you entered seems not to be a correct IP address')
  735. ## if valid ip address entered store it
  736.  
  737. if self.input_portno.get() != '':
  738. ## test if the portnumber seems to be a valid value
  739. try:
  740. check_portno = int(self.input_portno.get())
  741. if check_portno < 0:
  742. raise ValueError
  743. except ValueError:
  744. showerror('Port Number Error','the value you entered seems not to be a valid port number')
  745. return
  746. data.portno = int(self.input_portno.get())
  747.  
  748. if self.input_modbusid.get() != '':
  749. ## test if the modbus ID seems to be a valid value
  750. try:
  751. check_modbusid = int(self.input_portno.get())
  752. if check_modbusid < 0:
  753. raise ValueError
  754. except ValueError:
  755. showerror('Port Number Error','the value you entered seems not to be a valid Modbus ID')
  756. return
  757. data.modbusid = int(self.input_modbusid.get())
  758.  
  759. if self.input_manufacturer.get() != '':
  760. data.manufacturer = (self.input_manufacturer.get())
  761.  
  762. if self.input_loginterval.get() != '':
  763. ## test if the logger intervall seems to be a valid value
  764. try:
  765. check_loginterval = int(self.input_loginterval.get())
  766. if check_loginterval < 1:
  767. raise ValueError
  768. except ValueError:
  769. showerror('Logger Interval Error','the value you entered seems not to be a valid logger intervall')
  770. return
  771. data.loginterval = int(self.input_loginterval.get())
  772.  
  773. self.displayCommSettings()
  774.  
  775. ## function for starting communication and changing button function and text
  776. #
  777. def startCommunication(self):
  778. inout.runCommunication()
  779. self.commButton.configure(text='⏹ Stop Communication',bg='red', command=(self.stopCommunication))
  780.  
  781. def stopCommunication(self):
  782. inout.stopCommunication()
  783. self.commButton.configure(text='▶ Start Communication',bg='lightblue', command=(self.startCommunication))
  784.  
  785. ## function for reading configuration file
  786. #
  787. def selectImportFile(self):
  788. data.inifilename = askopenfilename(title = 'Choose Configuration File',defaultextension='.ini',filetypes=[('Configuration file','*.ini'), ('All files','*.*')])
  789.  
  790. ## update displayed filename in entry field
  791. self.input_inifilename.delete(0,END)
  792. self.input_inifilename.insert(0,data.inifilename)
  793.  
  794. self.displaySettings()
  795.  
  796. ## function for checking for seemingly correct IP address input
  797. #
  798. def ip_address(self,address):
  799. valid = address.split('.')
  800. if len(valid) != 4:
  801. raise ValueError
  802. for element in valid:
  803. if not element.isdigit():
  804. raise ValueError
  805. break
  806. i = int(element)
  807. if i < 0 or i > 255:
  808. raise ValueError
  809. return
  810.  
  811. ## function for selecting configuration export file
  812. #
  813. def selectExportFile(self):
  814. data.inifilename = asksaveasfilename(initialfile = data.inifilename,
  815. title = 'Choose Configuration File',
  816. defaultextension='.ini',
  817. filetypes=[('Configuration file','*.ini'), ('All files','*.*')])
  818.  
  819. ## update displayed filename in entry field
  820. self.input_inifilename.delete(0,END)
  821. self.input_inifilename.insert(0,data.inifilename)
  822.  
  823. inout.writeExportFile()
  824.  
  825. ## function for choosing logger data file
  826. #
  827. def selectLoggerDataFile(self):
  828. data.logfilename = asksaveasfilename(initialfile = data.logfilename, title = 'Choose File for Logger Data', defaultextension='.csv',filetypes=[('CSV file','*.csv'), ('All files','*.*')])
  829. self.input_logfilename.delete(0,END)
  830. self.input_logfilename.insert(0,data.logfilename)
  831.  
  832. ## function for updating the current received data on display
  833. #
  834. def updateLoggerDisplay(self):
  835. thisdata = '' ## make variable data known
  836. ## delete old data
  837. for displayed in self.targetdataframe.winfo_children():
  838. displayed.destroy()
  839. ## display new data
  840. Label(self.targetdataframe,text='Value').grid(row=0,column=0)
  841. for thisdata in data.datavector:
  842. ## send data to display table
  843. Label(self.targetdataframe,text=thisdata,bg='white').grid(column=0,sticky='e')
  844.  
  845. ## function for setting program preferences (if needed)
  846. #
  847. def dataSettings(self):
  848. print('dataSettings')
  849.  
  850. ## function for updating the configuration file
  851. # with the path entered into the text field
  852. #
  853. def getInputFile(self,event):
  854. data.inifilename = event.widget.get()
  855.  
  856. ## function for updating the log file path
  857. # with the path entered into the entry field
  858. #
  859. def setLogFile(self,event):
  860. data.logfilename = event.widget.get()
  861.  
  862. ## function adds dataset to the datasets list
  863. # also updates the displayed list
  864. # new datasets are not added to the config file
  865. #
  866. def addNewDataset(self):
  867. inout.addDataset([self.input_modaddress.get(),
  868. self.input_moddatatype.get(),
  869. self.input_dataformat.get(),
  870. self.input_description.get(),
  871. self.input_dataunit.get()])
  872. self.displayDatasets()
  873. #print (data.datasets)
  874.  
  875. ## function for displaying the about dialog
  876. #
  877. def aboutDialog(self):
  878. showinfo('About Python Modbus Monitor'\
  879. ,'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\"')
  880.  
  881. ## function for closing the program window
  882. #
  883. def closeWindow(self):
  884. exit()
  885.  
  886. ## create a data object
  887. data = Data()
  888.  
  889. ## create an input output object
  890. inout = Inout()
  891.  
  892. ## what to do on program exit
  893. atexit.register(inout.cleanOnExit)
  894.  
  895. ## create main program window
  896. ## if we are in command line mode lets detect it
  897. gui_active = 0
  898. if (arguments['--nogui'] == False):
  899. ## load graphical interface library
  900. from Tkinter import *
  901. from tkMessageBox import *
  902. from tkFileDialog import *
  903. try: ## if the program was called from command line without parameters
  904. window = Tk()
  905. ## create window container
  906. gui = Gui(window)
  907. gui_active = 1
  908. if (arguments['--inifile'] != None):
  909. inout.checkImportFile()
  910. gui.displaySettings()
  911.  
  912. mainloop()
  913. exit() ## if quitting from GUI do not proceed further down to command line handling
  914. except TclError:
  915. ## check if one of the required command line parameters is set
  916. if ((arguments['--inifile'] == None) and (arguments['--ip'] == None)):
  917. print 'Error. No graphical interface found. Try "python pymodmon.py -h" for help.'
  918. exit()
  919. ## else continue with command line execution
  920.  
  921. ######## this section handles all command line logic ##########################
  922.  
  923. ## read the configuration file
  924. if (arguments['--inifile'] != None):
  925. inout.checkImportFile()
  926.  
  927. ## get log file name and try to access it
  928. if (arguments['--logfile'] != None):
  929. data.logfilename = str(arguments['--logfile'])
  930. inout.writeLoggerDataFile() ## initial write to file, tests for file
  931.  
  932. ## get log interval value and check for valid value
  933. if (arguments['--loginterval'] != None):
  934. try:
  935. check_loginterval = int(arguments['--loginterval'])
  936. if check_loginterval < 1:
  937. raise ValueError
  938. except ValueError:
  939. print('Log interval error. The interval must be 1 or more.')
  940. exit()
  941. data.loginterval = int(arguments['--loginterval'])
  942.  
  943. ## get log buffer size and check for valid value
  944. if (arguments['--logbuffer'] != None):
  945. try:
  946. check_logbuffer = int(arguments['--logbuffer'])
  947. if check_logbuffer < 1:
  948. raise ValueError
  949. except ValueError:
  950. print('Log buffer error. The log buffer must be 1 or more.')
  951. exit()
  952. data.logmaxbuffer = int(arguments['--logbuffer'])
  953.  
  954. ## get all values for single-value reads
  955. ## all obligatory entries. missing entries will be caught by docopt.
  956. # only simple checks will be done, because if there are errors, communication will fail.
  957. if (arguments['--ip'] != None): ## just a check for flow logic, skipped when working with inifile
  958. data.ipaddress = str(arguments['--ip'])
  959. data.modbusid = int(arguments['--id'])
  960. data.port = int(arguments['--port'])
  961. ## because called from command line data.datasets has only one entry
  962. # we can just append and use same mechanics as in "normal" mode
  963. data.datasets.append( [int(arguments['--addr']),
  964. str(arguments['--type']),
  965. str(arguments['--format']),
  966. str(arguments['--descr']),
  967. str(arguments['--unit']) ] )
  968.  
  969. ## start polling data
  970. ## single poll first
  971. inout.runCommunication()
  972. ## if --single is set, exit immediately
  973. if (arguments['--single'] == True):
  974. inout.stopCommunication()
  975. print 'single run'
  976. exit()
Buy Me A Coffee