bin/radmonAgent.py
b528647b
 #!/usr/bin/python -u
4a2d49ab
 ## The -u option above turns off block buffering of python output. This 
 ## assures that each error message gets individually printed to the log file.
b528647b
 #
 # Module: radmonAgent.py
 #
4a2d49ab
 # Description: This module acts as an agent between the radiation monitoring
 # device and the Internet web server.  The agent periodically sends an http
 # request to the radiation monitoring device and processes the response from
 # the device and performs a number of operations:
b528647b
 #     - conversion of data items
 #     - update a round robin (rrdtool) database with the radiation data
 #     - periodically generate graphic charts for display in html documents
 #     - forward the radiation data to other services
4a2d49ab
 #     - write the processed weather data to a JSON file for use by html
 #       documents
b528647b
 #
 # Copyright 2015 Jeff Owrey
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU General Public License as published by
 #    the Free Software Foundation, either version 3 of the License, or
 #    (at your option) any later version.
 #
 #    This program 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 General Public License for more details.
 #
 #    You should have received a copy of the GNU General Public License
 #    along with this program.  If not, see http://www.gnu.org/license.
 #
 # Revision History
4a2d49ab
 #   * v20 released 15 Sep 2015 by J L Owrey; first release
 #   * v21 released 27 Nov 2017 by J L Owrey; bug fixes; updates
b528647b
 #
58737d5f
 
1050ddd1
 _MIRROR_SERVER = False
4a2d49ab
 
58737d5f
 import os
b528647b
 import urllib2
58737d5f
 import sys   
b528647b
 import subprocess
 import multiprocessing
58737d5f
 import time
 import calendar
 
 _USER = os.environ['USER']
 
    ### DEFAULT WEATHER STATION URL ###
 
1050ddd1
 # ip address of radiation monitoring device
 _RADIATION_MONITOR_URL = "http://YOUR_RADIATION_MONITOR_IP_ADDRESS"
 # url if this is a mirror server
 _PRIMARY_SERVER_URL = \
     "http://YOUR_PRIMARY_SERVER/USER/radmon/dynamic/radmonInputData.dat"
b528647b
 
66d7b126
     ### FILE AND FOLDER LOCATIONS ###
 
4a2d49ab
 # folder for containing dynamic data objects
1050ddd1
 _DOCROOT_PATH = "/home/%s/public_html/radmon/" % _USER
4a2d49ab
 # folder for charts and output data file
1050ddd1
 _CHARTS_DIRECTORY = _DOCROOT_PATH + "dynamic/"
4a2d49ab
  # database that stores weather data
 _RRD_FILE = "/home/%s/database/radmonData.rrd" % _USER
1050ddd1
 # location of data input file
 INPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/radmonInputData.dat"
4a2d49ab
 # location of data output file
1050ddd1
 _OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/radmonOutputData.js"
66d7b126
 
     ### GLOBAL CONSTANTS ###
4a2d49ab
 # interval in seconds between data requests to radiation monitor
 _DEFAULT_DATA_REQUEST_INTERVAL = 5
 # defines how often the charts get updated in seconds
 _CHART_UPDATE_INTERVAL = 300
 # defines how often the database gets updated
 _DATABASE_UPDATE_INTERVAL = 30
 # number seconds to wait for a response to HTTP request
 _HTTP_REQUEST_TIMEOUT = 3
 # max number of failed data requests allowed
 _MAX_RADIATION_MONITOR_OFFLINE_COUNT = 2
 # radmon chart dimensions
98c8ae93
 _CHART_WIDTH = 600
 _CHART_HEIGHT = 150
b528647b
 
66d7b126
    ### GLOBAL VARIABLES ###
 
4a2d49ab
 # turn on or off of verbose debugging information
b528647b
 debugOption = False
4a2d49ab
 # online status of radiation monitor
f555f41f
 radiationMonitorOnline = True
4a2d49ab
 # number of unsuccessful http requests
f555f41f
 radiationMonitorOfflineCount = 0
4a2d49ab
 # status of reset command to radiation monitor
 remoteDeviceReset = False
1050ddd1
 # ip address of radiation monitor
 radiationMonitorUrl = _RADIATION_MONITOR_URL
4a2d49ab
 # web update frequency
 dataRequestInterval = _DEFAULT_DATA_REQUEST_INTERVAL
0979d8dd
 
b528647b
   ###  PRIVATE METHODS  ###
 
 def getTimeStamp():
     """
     Sets the error message time stamp to the local system time.
     Parameters: none
     Returns string containing the time stamp.
     """
f555f41f
     return time.strftime( "%m/%d/%Y %T", time.localtime() )
b528647b
 ##end def
 
0979d8dd
 def setOfflineStatus(dData):
     """Set the status of the the upstream device to "offline" and sends
b528647b
        blank data to the downstream clients.
0979d8dd
        Parameters:
            dData - dictionary object containing weather data
b528647b
        Returns nothing.
     """
f555f41f
     global radiationMonitorOnline, radiationMonitorOfflineCount
 
     radiationMonitorOfflineCount += 1
 
     if radiationMonitorOfflineCount < _MAX_RADIATION_MONITOR_OFFLINE_COUNT:
         return
0979d8dd
 
     # If the radiation monitor was previously online, then send a message
     # that we are now offline.
f555f41f
     if radiationMonitorOnline:
         print "%s: radiation monitor offline" % getTimeStamp()
1050ddd1
         if os.path.exists(INPUT_DATA_FILE):
             os.remove(INPUT_DATA_FILE)
f555f41f
         radiationMonitorOnline = False
0979d8dd
 
4a2d49ab
     for key in dData:
         dData[key] = ''
 
0979d8dd
     dData['status'] = 'offline'
 
     writeOutputDataFile(dData)
b528647b
     return
 ##end def
 
   ###  PUBLIC METHODS  ###
 
f555f41f
 def getRadiationData():
0979d8dd
     """Send http request to radiation monitoring device.  The response
        from the device contains the radiation data.  The data is formatted
        as an html document.
     Parameters: 
f555f41f
         radiationMonitorUrl - url of radiation monitoring device
0979d8dd
         HttpRequesttimeout - how long to wait for device
                              to respond to http request
     Returns a string containing the radiation data, or None if
     not successful.
     """
4a2d49ab
     global radiationMonitorOnline, radiationMonitorOfflineCount, \
            remoteDeviceReset
 
1050ddd1
     if _MIRROR_SERVER:
         sUrl = _PRIMARY_SERVER_URL
     elif remoteDeviceReset:
         sUrl = radiationMonitorUrl + "/reset"
     else:
         sUrl = radiationMonitorUrl + "/rdata"
0979d8dd
 
     try:
4a2d49ab
         conn = urllib2.urlopen(sUrl, timeout=_HTTP_REQUEST_TIMEOUT)
f555f41f
 
         # Format received data into a single string.
         content = ""
         for line in conn:
             content += line.strip()
         del conn
 
0979d8dd
     except Exception, exError:
         # If no response is received from the device, then assume that
         # the device is down or unavailable over the network.  In
         # that case set the status of the device to offline.
         if debugOption:
f555f41f
             print "http error: %s" % exError
0979d8dd
         return None
 
f555f41f
     radiationMonitorOfflineCount = 0
 
0979d8dd
     # If the radiation monitor was previously offline, then send a message
     # that we are now online.
f555f41f
     if not radiationMonitorOnline:
         print "%s radiation monitor online" % getTimeStamp()
         radiationMonitorOnline = True
 
1050ddd1
     #print content
0979d8dd
     return content
b528647b
 ##end def
 
0979d8dd
 def parseDataString(sData, dData):
b528647b
     """Parse the radiation data JSON string from the radiation 
        monitoring device into its component parts.  
        Parameters:
            sData - the string containing the data to be parsed
            dData - a dictionary object to contain the parsed data items
        Returns true if successful, false otherwise.
     """
     try:
0979d8dd
         sTmp = sData[2:-2]
b528647b
         lsTmp = sTmp.split(',')
     except Exception, exError:
0979d8dd
         print "%s parseDataString: %s" % (getTimeStamp(), exError)
b528647b
         return False
 
     # Load the parsed data into a dictionary for easy access.
0979d8dd
     for item in lsTmp:
b528647b
         if "=" in item:
             dData[item.split('=')[0]] = item.split('=')[1]
0979d8dd
     dData['status'] = 'online'
b528647b
 
1050ddd1
     if len(dData) != 6:
         print "%s parse failed: corrupted data string: %s" % \
                (getTimeStamp(), sData)
         return False;
 
b528647b
     return True
 ##end def
 
0979d8dd
 def convertData(dData):
b528647b
     """Convert individual radiation data items as necessary.
        Parameters:
            lsData - a list object containing the radiation data
            dData - a dictionary object containing the radiation data
        Returns true if successful, false otherwise.
     """
     result = True
  
     try:
4a2d49ab
 
         # Uncomment below to use timestamp from radiation monitoring device
         # otherwise the requesting server (this) will generate the
         # timestamp. Allowing the server to generate the timestamp
         # prevents timestamp errors due to the radiation monitoring device
         # failing to synchronize with a NTP time server.
 
         #dData['UTC'] = time.time()
 
         ## Convert UTC from radiation monitoring device to local time.
b528647b
         ts_utc = time.strptime(dData['UTC'], "%H:%M:%S %m/%d/%Y")
         local_sec = calendar.timegm(ts_utc)
         dData['UTC'] = local_sec
4a2d49ab
         
92145152
         dData['Mode'] = dData['Mode'].lower()
4a2d49ab
  
         dData['uSvPerHr'] = float(dData.pop('uSv/hr'))
         
         dData['CPM'] = int(dData.pop('CPM'))
 
0979d8dd
     except Exception, exError:
         print "%s convertData: %s" % (getTimeStamp(), exError)
         result = False
b528647b
 
     return result
 ##end def
 
0979d8dd
 def writeOutputDataFile(dData):
b528647b
     """Convert individual weather string data items as necessary.
        Parameters:
            lsData - a list object containing the data to be written
                     to the JSON file
        Returns true if successful, false otherwise.
     """
0979d8dd
     # Set date to current time and data
     dData['date'] = getTimeStamp()
b528647b
 
0979d8dd
     # Format the weather data as string using java script object notation.
     sData = '[{'
     for key in dData:
         sData += "\"%s\":\"%s\"," % (key, dData[key])
92145152
     sData = sData[:-1] + '}]'
b528647b
 
0979d8dd
     # Write the string to the output data file for use by html documents.
b528647b
     try:
         fc = open(_OUTPUT_DATA_FILE, "w")
66d7b126
         fc.write(sData)
b528647b
         fc.close()
     except Exception, exError:
0979d8dd
         print "%s writeOutputDataFile: %s" % (getTimeStamp(), exError)
b528647b
         return False
66d7b126
 
98c8ae93
     if debugOption and 0:
0979d8dd
         print sData
 
b528647b
     return True
 ## end def
 
1050ddd1
 def writeInputDataFile(sData):
58737d5f
     # Write the string to the output data file for use by html documents.
1050ddd1
     sData += "\n"
58737d5f
     try:
1050ddd1
         fc = open(INPUT_DATA_FILE, "w")
58737d5f
         fc.write(sData)
         fc.close()
     except Exception, exError:
         print "%s writeOutputDataFile: %s" % (getTimeStamp(), exError)
         return False
 
     return True
 ##end def
 
b528647b
 def updateDatabase(dData):
     """
     Updates the rrdtool database by executing an rrdtool system command.
     Formats the command using the data extracted from the radiation
     monitor response.   
     Parameters: dData - dictionary object containing data items to be
                         written to the rr database file
     Returns true if successful, false otherwise.
     """
4a2d49ab
     global remoteDeviceReset
 
     # The RR database stores whole units, so convert uSv to Sv.
     #Svvalue = float(dData['uSvPerHr']) * 1.0E-06
     Svvalue = dData['uSvPerHr'] * 1.0E-06 
b528647b
 
     # Create the rrdtool update command.
     strCmd = "rrdtool update %s %s:%s:%s" % \
                        (_RRD_FILE, dData['UTC'], dData['CPM'], Svvalue)
     if debugOption:
f555f41f
         print "%s" % strCmd # DEBUG
b528647b
 
     # Run the command as a subprocess.
     try:
         subprocess.check_output(strCmd, shell=True,  \
                              stderr=subprocess.STDOUT)
     except subprocess.CalledProcessError, exError:
         print "%s: rrdtool update failed: %s" % \
                                  (getTimeStamp(), exError.output)
4a2d49ab
         if exError.output.find("illegal attempt to update using time") > -1:
             remoteDeviceReset = True
             print "%s: rebooting radiation monitor" % \
                                  (getTimeStamp())
 
b528647b
         return False
 
     return True
 ##end def
 
f555f41f
 def createGraph(fileName, dataItem, gLabel, gTitle, gStart,
                 lower, upper, addTrend, autoScale):
b528647b
     """Uses rrdtool to create a graph of specified weather data item.
        Parameters:
            fileName - name of graph image file
            dataItem - data item to be graphed
f555f41f
            gLabel - string containing a graph label for the data item
            gTitle - string containing a title for the graph
            lower - lower bound for graph ordinate #NOT USED
            upper - upper bound for graph ordinate #NOT USED
            addTrend - 0, show only graph data
                       1, show only a trend line
                       2, show a trend line and the graph data
            autoScale - if True, then use vertical axis auto scaling
                (lower and upper parameters are ignored), otherwise use
                lower and upper parameters to set vertical axis scale
b528647b
        Returns true if successful, false otherwise.
     """
4a2d49ab
     gPath = _CHARTS_DIRECTORY + fileName + ".png"
98c8ae93
     trendWindow = { 'end-1day': 7200,
                     'end-4weeks': 172800,
                     'end-12months': 604800 }
  
     # Format the rrdtool graph command.
 
     # Set chart start time, height, and width.
     strCmd = "rrdtool graph %s -a PNG -s %s -e now -w %s -h %s " \
              % (gPath, gStart, _CHART_WIDTH, _CHART_HEIGHT)
    
f555f41f
     # Set the range and scaling of the chart y-axis.
98c8ae93
     if lower < upper:
f555f41f
         strCmd  +=  "-l %s -u %s -r " % (lower, upper)
     elif autoScale:
         strCmd += "-A "
     strCmd += "-Y "
98c8ae93
 
     # Set the chart ordinate label and chart title. 
     strCmd += "-v %s -t %s " % (gLabel, gTitle)
f555f41f
  
98c8ae93
     # Show the data, or a moving average trend line over
     # the data, or both.
f555f41f
     strCmd += "DEF:dSeries=%s:%s:LAST " % (_RRD_FILE, dataItem)
     if addTrend == 0:
         strCmd += "LINE1:dSeries#0400ff "
     elif addTrend == 1:
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE3:smoothed#ff0000 " \
                   % trendWindow[gStart]
     elif addTrend == 2:
         strCmd += "LINE1:dSeries#0400ff "
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE3:smoothed#ff0000 " \
                   % trendWindow[gStart]
      
b528647b
     if debugOption:
         print "%s\n" % strCmd # DEBUG
     
98c8ae93
     # Run the formatted rrdtool command as a subprocess.
b528647b
     try:
98c8ae93
         result = subprocess.check_output(strCmd, \
                      stderr=subprocess.STDOUT,   \
b528647b
                      shell=True)
     except subprocess.CalledProcessError, exError:
98c8ae93
         print "rrdtool graph failed: %s" % (exError.output)
b528647b
         return False
 
     if debugOption:
         print "rrdtool graph: %s" % result
     return True
98c8ae93
 
b528647b
 ##end def
 
f555f41f
 def generateGraphs():
     """Generate graphs for display in html documents.
        Parameters: none
        Returns nothing.
     """
     autoScale = False
 
58737d5f
     createGraph('24hr_cpm', 'CPM', 'counts\ per\ minute', 
f555f41f
                 'CPM\ -\ Last\ 24\ Hours', 'end-1day', 0, 0, 2, autoScale)
58737d5f
     createGraph('24hr_svperhr', 'SvperHr', 'Sv\ per\ hour',
f555f41f
                 'Sv/Hr\ -\ Last\ 24\ Hours', 'end-1day', 0, 0, 2, autoScale)
58737d5f
     createGraph('4wk_cpm', 'CPM', 'counts\ per\ minute',
f555f41f
                 'CPM\ -\ Last\ 4\ Weeks', 'end-4weeks', 0, 0, 2, autoScale)
58737d5f
     createGraph('4wk_svperhr', 'SvperHr', 'Sv\ per\ hour',
f555f41f
                 'Sv/Hr\ -\ Last\ 4\ Weeks', 'end-4weeks', 0, 0, 2, autoScale)
58737d5f
     createGraph('12m_cpm', 'CPM', 'counts\ per\ minute',
f555f41f
                 'CPM\ -\ Past\ Year', 'end-12months', 0, 0, 2, autoScale)
58737d5f
     createGraph('12m_svperhr', 'SvperHr', 'Sv\ per\ hour',
f555f41f
                 'Sv/Hr\ -\ Past\ Year', 'end-12months', 0, 0, 2, autoScale)
 ##end def
 
b528647b
 def getCLarguments():
     """Get command line arguments.  There are three possible arguments
           -d turns on debug mode
           -t sets the radiation device query interval
           -u sets the url of the radiation monitoring device
        Returns nothing.
     """
f555f41f
     global debugOption, dataRequestInterval, radiationMonitorUrl
b528647b
 
     index = 1
     while index < len(sys.argv):
         if sys.argv[index] == '-d':
             debugOption = True
         elif sys.argv[index] == '-t':
             try:
f555f41f
                 dataRequestInterval = abs(int(sys.argv[index + 1]))
b528647b
             except:
                 print "invalid polling period"
                 exit(-1)
             index += 1
         elif sys.argv[index] == '-u':
f555f41f
             radiationMonitorUrl = sys.argv[index + 1]
b528647b
             index += 1
         else:
             cmd_name = sys.argv[0].split('/')
f555f41f
             print "Usage: %s [-d] [-t seconds] [-u url}" % cmd_name[-1]
b528647b
             exit(-1)
         index += 1
 ##end def
 
 def main():
4a2d49ab
     """Handles timing of events and acts as executive routine managing
        all other functions.
b528647b
        Parameters: none
        Returns nothing.
     """
66d7b126
 
4a2d49ab
     # last time output JSON file updated
     lastDataRequestTime = -1
     # last time charts generated
     lastChartUpdateTime = - 1
     # last time the rrdtool database updated
     lastDatabaseUpdateTime = -1
 
     # define empty dictionary object for radmon data
     dData = {}
b528647b
 
     ## Get command line arguments.
     getCLarguments()
 
f555f41f
     ## Exit with error if rrdtool database does not exist.
b528647b
     if not os.path.exists(_RRD_FILE):
f555f41f
         print "cannot find rrdtool database\nuse createWeatherRrd script to" \
               " create rrdtool database\n"
b528647b
         exit(1)
  
     ## main loop
     while True:
 
f555f41f
         currentTime = time.time() # get current time in seconds
b528647b
 
f555f41f
         # Every web update interval request data from the radiation
         # monitor and process the received data.
         if currentTime - lastDataRequestTime > dataRequestInterval:
             lastDataRequestTime = currentTime
b528647b
             result = True
 
             # Get the data string from the device.
f555f41f
             sData = getRadiationData()
b528647b
             if sData == None:
0979d8dd
                 setOfflineStatus(dData)
b528647b
                 result = False
 
             # If successful parse the data.
             if result:
4a2d49ab
                 dData = {}
0979d8dd
                 result = parseDataString(sData, dData)
b528647b
 
             # If parsing successful, convert the data.
             if result:
0979d8dd
                 result = convertData(dData)
66d7b126
 
             # If conversion successful, write data to output file.
b528647b
             if result:
1050ddd1
                 writeInputDataFile(sData)
0979d8dd
                 writeOutputDataFile(dData)
58737d5f
                 if debugOption:
                     print "http request successful"
b528647b
 
cc0b13e1
                 # At the rrdtool database update interval, update the database.
4a2d49ab
                 if currentTime - lastDatabaseUpdateTime > \
                         _DATABASE_UPDATE_INTERVAL:   
cc0b13e1
                     lastDatabaseUpdateTime = currentTime
                     ## Update the round robin database with the parsed data.
                     result = updateDatabase(dData)
b528647b
 
         # At the chart generation interval, generate charts.
         if currentTime - lastChartUpdateTime > _CHART_UPDATE_INTERVAL:
             lastChartUpdateTime = currentTime
             p = multiprocessing.Process(target=generateGraphs, args=())
             p.start()
 
         # Relinquish processing back to the operating system until
         # the next update interval.
 
         elapsedTime = time.time() - currentTime
         if debugOption:
f555f41f
             print "processing time: %6f sec\n" % elapsedTime
         remainingTime = dataRequestInterval - elapsedTime
         if remainingTime > 0.0:
b528647b
             time.sleep(remainingTime)
     ## end while
f555f41f
     return
b528647b
 ## end def
 
 if __name__ == '__main__':
     main()