bin/radmonAgent.py
d2bf8515
 #!/usr/bin/python3 -u
c467ff8b
 # 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
bac3cd9f
 # device and Internet web services.  The agent periodically sends an http
4a2d49ab
 # request to the radiation monitoring device and processes the response from
 # the device and performs a number of operations:
bac3cd9f
 #     - conversion of data items
b528647b
 #     - update a round robin (rrdtool) database with the radiation data
 #     - periodically generate graphic charts for display in html documents
e50a3c08
 #     - write the processed radmon data to a JSON file for use by html
4a2d49ab
 #       documents
b528647b
 #
0bfe4f11
 # Copyright 2015 Jeff Owrey
b528647b
 #    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
0a313e87
 #   * v22 released 03 Mar 2018 by J L Owrey; improved code readability;
 #         improved radmon device offline status handling
68be30fb
 #   * v23 released 16 Nov 2018 by J L Owrey: improved fault handling
 #         and data conversion
d2bf8515
 #   * v24 released 14 Jun 2021 by J L Owrey; minor revisions
24d02d7b
 #   * v25 released 9 Jul 2021 by J L Owrey; improved handling of
 #         monitor status function
d2bf8515
 #
e50a3c08
 #2345678901234567890123456789012345678901234567890123456789012345678901234567890
58737d5f
 
 import os
f7a9cacf
 import sys
 import signal
b528647b
 import subprocess
 import multiprocessing
58737d5f
 import time
 import calendar
d2bf8515
 import json
 from urllib.request import urlopen
 
    ### ENVIRONMENT ###
58737d5f
 
 _USER = os.environ['USER']
d2bf8515
 _SERVER_MODE = "primary"
 _USE_RADMON_TIMESTAMP = True
58737d5f
 
c467ff8b
    ### DEFAULT RADIATION MONITOR URL ###
58737d5f
 
d2bf8515
 _DEFAULT_RADIATION_MONITOR_URL = \
24d02d7b
     "http://192.168.1.24"
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
 # location of data output file
d2bf8515
 _OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/radmonData.js"
e50a3c08
 # database that stores radmon data
c467ff8b
 _RRD_FILE = "/home/%s/database/radmonData.rrd" % _USER
66d7b126
 
     ### GLOBAL CONSTANTS ###
bac3cd9f
 
f7a9cacf
 # max number of failed data requests allowed
d2bf8515
 _MAX_FAILED_DATA_REQUESTS = 3
 # interval in seconds between data requests
 _DEFAULT_DATA_REQUEST_INTERVAL = 2
4a2d49ab
 # number seconds to wait for a response to HTTP request
 _HTTP_REQUEST_TIMEOUT = 3
d2bf8515
 
 # interval in seconds between database updates
 _DATABASE_UPDATE_INTERVAL = 30
 # interval in seconds between chart updates
 _CHART_UPDATE_INTERVAL = 300
f7a9cacf
 # standard chart width in pixels
98c8ae93
 _CHART_WIDTH = 600
f7a9cacf
 # standard chart height in pixels
98c8ae93
 _CHART_HEIGHT = 150
b528647b
 
66d7b126
    ### GLOBAL VARIABLES ###
 
4a2d49ab
 # turn on or off of verbose debugging information
d2bf8515
 verboseMode = False
 debugMode = False
bac3cd9f
 
 # The following two items are used for detecting system faults
 # and radiation monitor online or offline status.
 # count of failed attempts to get data from radiation monitor
0bfe4f11
 failedUpdateCount = 0
bac3cd9f
 # detected status of radiation monitor device
24d02d7b
 radmonOnline = False
bac3cd9f
 
4a2d49ab
 # status of reset command to radiation monitor
 remoteDeviceReset = False
1050ddd1
 # ip address of radiation monitor
207514f1
 radiationMonitorUrl = _DEFAULT_RADIATION_MONITOR_URL
4a2d49ab
 # web update frequency
 dataRequestInterval = _DEFAULT_DATA_REQUEST_INTERVAL
0979d8dd
 
b528647b
   ###  PRIVATE METHODS  ###
 
 def getTimeStamp():
     """
bac3cd9f
     Set the error message time stamp to the local system time.
b528647b
     Parameters: none
bac3cd9f
     Returns: string containing the time stamp
b528647b
     """
f555f41f
     return time.strftime( "%m/%d/%Y %T", time.localtime() )
b528647b
 ##end def
 
0bfe4f11
 def setStatusToOffline():
bac3cd9f
     """Set the detected status of the radiation monitor to
        "offline" and inform downstream clients by removing input
        and output data files.
        Parameters: none
        Returns: nothing
b528647b
     """
d2bf8515
     global radmonOnline
f555f41f
 
e50a3c08
     # Inform downstream clients by removing output data file.
0bfe4f11
     if os.path.exists(_OUTPUT_DATA_FILE):
        os.remove(_OUTPUT_DATA_FILE)
bac3cd9f
     # If the radiation monitor was previously online, then send
     # a message that we are now offline.
d2bf8515
     if radmonOnline:
         print('%s radiation monitor offline' % getTimeStamp())
     radmonOnline = False
b528647b
 ##end def
 
0bfe4f11
 def terminateAgentProcess(signal, frame):
bac3cd9f
     """Send a message to log when the agent process gets killed
        by the operating system.  Inform downstream clients
        by removing input and output data files.
        Parameters:
            signal, frame - dummy parameters
f7a9cacf
        Returns: nothing
     """
d2bf8515
     print('%s terminating radmon agent process' % \
               (getTimeStamp()))
24d02d7b
     setStatusToOffline()
f7a9cacf
     sys.exit(0)
 ##end def
 
b528647b
   ###  PUBLIC METHODS  ###
 
d2bf8515
 def getRadiationData(dData):
bac3cd9f
     """Send http request to radiation monitoring device.  The
        response from the device contains the radiation data as
        unformatted ascii text.
        Parameters: none 
        Returns: a string containing the radiation data if successful,
                 or None if not successful
0979d8dd
     """
8e0b939c
     sUrl = radiationMonitorUrl
 
     if remoteDeviceReset:
         sUrl += "/reset" # reboot the radiation monitor
1050ddd1
     else:
8e0b939c
         sUrl += "/rdata" # request data from the monitor
0979d8dd
 
     try:
d2bf8515
         currentTime = time.time()
         response = urlopen(sUrl, timeout=_HTTP_REQUEST_TIMEOUT)
24d02d7b
         requestTime = time.time() - currentTime
f555f41f
 
d2bf8515
         content = response.read().decode('utf-8')
         content = content.replace('\n', '')
         content = content.replace('\r', '')
         if content == "":
             raise Exception("empty response")
f555f41f
 
d2bf8515
     except Exception as exError:
0979d8dd
         # If no response is received from the device, then assume that
         # the device is down or unavailable over the network.  In
bac3cd9f
         # that case return None to the calling function.
d2bf8515
         if verboseMode:
             print("%s getRadiationData: %s" % (getTimeStamp(), exError))
         return False
     ##end try
 
     if debugMode:
         print(content)
24d02d7b
     if verboseMode:
         print("http request successful: %.4f sec" % requestTime)
d2bf8515
     
     dData['content'] = content
     return True
b528647b
 ##end def
 
d2bf8515
 def parseDataString(dData):
     """Parse the data string returned by the radiation monitor
        into its component parts.
b528647b
        Parameters:
d2bf8515
             dData - a dictionary object to contain the parsed data items
bac3cd9f
        Returns: True if successful, False otherwise
b528647b
     """
d2bf8515
     # Example radiation monitor data string
     # $,UTC=17:09:33 6/22/2021,CPS=0,CPM=26,uSv/hr=0.14,Mode=SLOW,#
     
b528647b
     try:
d2bf8515
         sData = dData.pop('content')
         lData = sData[2:-2].split(',')
     except Exception as exError:
         print("%s parseDataString: %s" % (getTimeStamp(), exError))
b528647b
         return False
 
24d02d7b
     # Verfy the expected number of data items have been received.
     if len(lData) != 5:
         print("%s parse failed: corrupted data string" % getTimeStamp())
         return False;
 
b528647b
     # Load the parsed data into a dictionary for easy access.
d2bf8515
     for item in lData:
b528647b
         if "=" in item:
             dData[item.split('=')[0]] = item.split('=')[1]
24d02d7b
 
d2bf8515
     # Add status to dictionary object
0979d8dd
     dData['status'] = 'online'
24d02d7b
     dData['serverMode'] = _SERVER_MODE
1050ddd1
 
b528647b
     return True
 ##end def
 
0979d8dd
 def convertData(dData):
b528647b
     """Convert individual radiation data items as necessary.
        Parameters:
            dData - a dictionary object containing the radiation data
bac3cd9f
        Returns: True if successful, False otherwise
b528647b
     """
     try:
e50a3c08
         if _USE_RADMON_TIMESTAMP:
             # Convert the UTC timestamp provided by the radiation monitoring
             # device to epoch local time in seconds.
             ts_utc = time.strptime(dData['UTC'], "%H:%M:%S %m/%d/%Y")
             epoch_local_sec = calendar.timegm(ts_utc)
             dData['ELT'] = epoch_local_sec
         else:
             # Use a timestamp generated by the requesting server (this)
             # instead of the timestamp provided by the radiation monitoring
             # device.  Using the server generated timestamp prevents errors
             # that occur when the radiation monitoring device fails to
             # synchronize with a valid NTP time server.
             dData['ELT'] = time.time()
d2bf8515
 
         dData['date'] = \
             time.strftime("%m/%d/%Y %T", time.localtime(dData['ELT']))      
         dData['mode'] = dData.pop('Mode').lower()
68be30fb
         dData['uSvPerHr'] = '%.2f' % float(dData.pop('uSv/hr'))
207514f1
 
d2bf8515
     except Exception as exError:
         print("%s data conversion failed: %s" % (getTimeStamp(), exError))
bac3cd9f
         return False
b528647b
 
bac3cd9f
     return True
b528647b
 ##end def
 
d2bf8515
 def writeOutputFile(dData):
bac3cd9f
     """Write radiation data items to the output data file, formatted as 
d2bf8515
        a JSON file.  This file may then be accessed and used by
bac3cd9f
        by downstream clients, for instance, in HTML documents.
b528647b
        Parameters:
bac3cd9f
            dData - a dictionary object containing the data to be written
                    to the output data file
        Returns: True if successful, False otherwise
b528647b
     """
e50a3c08
     # Format the radmon data as string using java script object notation.
d2bf8515
     jsData = json.loads("{}")
     try:
24d02d7b
         for key in dData:
             jsData.update({key:dData[key]})
d2bf8515
         sData = "[%s]" % json.dumps(jsData)
     except Exception as exError:
         print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
         return False
b528647b
 
d2bf8515
     if debugMode:
         print(sData)
8e0b939c
 
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()
d2bf8515
     except Exception as exError:
         print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
b528647b
         return False
66d7b126
 
b528647b
     return True
 ## end def
 
24d02d7b
 def setRadmonStatus(updateSuccess):
     """Detect if radiation monitor is offline or not available on
        the network. After a set number of attempts to get data
        from the monitor set a flag that the radmon is offline.
        Parameters:
            updateSuccess - a boolean that is True if data request
                            successful, False otherwise
        Returns: nothing
     """
     global failedUpdateCount, radmonOnline
 
     if updateSuccess:
         failedUpdateCount = 0
         # Set status and send a message to the log if the device
         # previously offline and is now online.
         if not radmonOnline:
             print('%s radiation monitor online' % getTimeStamp())
             radmonOnline = True
         return
     elif failedUpdateCount == _MAX_FAILED_DATA_REQUESTS - 1:
         # Max number of failed data requests, so set
         # device status to offline.
         setStatusToOffline()
     ## end if
     failedUpdateCount += 1
 ##end def
 
     ### DATABASE FUNCTIONS ###
 
b528647b
 def updateDatabase(dData):
     """
bac3cd9f
     Update the rrdtool database by executing an rrdtool system command.
     Format the command using the data extracted from the radiation
b528647b
     monitor response.   
     Parameters: dData - dictionary object containing data items to be
                         written to the rr database file
bac3cd9f
     Returns: True if successful, False otherwise
b528647b
     """
4a2d49ab
     global remoteDeviceReset
 
     # The RR database stores whole units, so convert uSv to Sv.
68be30fb
     SvPerHr = float(dData['uSvPerHr']) * 1.0E-06 
b528647b
 
bac3cd9f
     # Format the rrdtool update command.
b528647b
     strCmd = "rrdtool update %s %s:%s:%s" % \
c467ff8b
                        (_RRD_FILE, dData['ELT'], dData['CPM'], SvPerHr)
d2bf8515
     if debugMode:
         print("%s" % strCmd) # DEBUG
b528647b
 
     # Run the command as a subprocess.
     try:
         subprocess.check_output(strCmd, shell=True,  \
                              stderr=subprocess.STDOUT)
d2bf8515
     except subprocess.CalledProcessError as exError:
         print("%s: rrdtool update failed: %s" % \
                     (getTimeStamp(), exError.output))
4a2d49ab
         if exError.output.find("illegal attempt to update using time") > -1:
             remoteDeviceReset = True
d2bf8515
             print("%s: rebooting radiation monitor" % (getTimeStamp()))
b528647b
         return False
d2bf8515
 
     if verboseMode and not debugMode:
24d02d7b
         print("database update successful")
d2bf8515
 
     return True
 ##end def
 
f555f41f
 def createGraph(fileName, dataItem, gLabel, gTitle, gStart,
                 lower, upper, addTrend, autoScale):
e50a3c08
     """Uses rrdtool to create a graph of specified radmon data item.
b528647b
        Parameters:
bac3cd9f
            fileName - name of file containing the graph
b528647b
            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
bac3cd9f
            gStart - beginning time of the graphed data
f555f41f
            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
bac3cd9f
        Returns: True if successful, False otherwise
b528647b
     """
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:
d2bf8515
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed#006600 " \
f555f41f
                   % trendWindow[gStart]
     elif addTrend == 2:
         strCmd += "LINE1:dSeries#0400ff "
d2bf8515
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed#006600 " \
f555f41f
                   % trendWindow[gStart]
      
d2bf8515
     if debugMode:
         print("\n%s" % strCmd) # DEBUG
b528647b
     
98c8ae93
     # Run the formatted rrdtool command as a subprocess.
b528647b
     try:
98c8ae93
         result = subprocess.check_output(strCmd, \
                      stderr=subprocess.STDOUT,   \
b528647b
                      shell=True)
d2bf8515
     except subprocess.CalledProcessError as exError:
         print("rrdtool graph failed: %s" % (exError.output))
b528647b
         return False
 
d2bf8515
     if verboseMode:
         print("rrdtool graph: %s" % result.decode('utf-8'), end='')
b528647b
     return True
98c8ae93
 
b528647b
 ##end def
 
f555f41f
 def generateGraphs():
     """Generate graphs for display in html documents.
        Parameters: none
bac3cd9f
        Returns: nothing
f555f41f
     """
     autoScale = False
 
d2bf8515
     # past 24 hours
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)
d2bf8515
     # past 4 weeks
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)
d2bf8515
     # past year
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():
bac3cd9f
     """Get command line arguments.  There are four possible arguments
b528647b
           -d turns on debug mode
d2bf8515
           -v turns on verbose mode
b528647b
           -t sets the radiation device query interval
           -u sets the url of the radiation monitoring device
bac3cd9f
        Returns: nothing
b528647b
     """
d2bf8515
     global verboseMode, debugMode, dataRequestInterval, \
bac3cd9f
            radiationMonitorUrl
b528647b
 
     index = 1
     while index < len(sys.argv):
d2bf8515
         if sys.argv[index] == '-v':
             verboseMode = True
         elif sys.argv[index] == '-d':
             verboseMode = True
             debugMode = True
b528647b
         elif sys.argv[index] == '-t':
d2bf8515
             dataRequestInterval = abs(int(sys.argv[index + 1]))
b528647b
             index += 1
         elif sys.argv[index] == '-u':
f555f41f
             radiationMonitorUrl = sys.argv[index + 1]
d2bf8515
             if radiationMonitorUrl.find('http://') < 0:
                 radiationMonitorUrl = 'http://' + radiationMonitorUrl
b528647b
             index += 1
         else:
             cmd_name = sys.argv[0].split('/')
d2bf8515
             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
bac3cd9f
        Returns: nothing
b528647b
     """
0bfe4f11
     signal.signal(signal.SIGTERM, terminateAgentProcess)
d2bf8515
     signal.signal(signal.SIGINT, terminateAgentProcess)
f7a9cacf
 
24d02d7b
     print('===================')
d2bf8515
     print('%s starting up radmon agent process' % \
                   (getTimeStamp()))
66d7b126
 
4a2d49ab
     # last time output JSON file updated
     lastDataRequestTime = -1
     # last time charts generated
     lastChartUpdateTime = - 1
     # last time the rrdtool database updated
     lastDatabaseUpdateTime = -1
 
b528647b
     ## Get command line arguments.
     getCLarguments()
 
f555f41f
     ## Exit with error if rrdtool database does not exist.
b528647b
     if not os.path.exists(_RRD_FILE):
d2bf8515
         print('rrdtool database does not exist\n' \
e50a3c08
               'use createRadmonRrd script to ' \
d2bf8515
               'create rrdtool database\n')
b528647b
         exit(1)
  
     ## main loop
     while True:
 
f555f41f
         currentTime = time.time() # get current time in seconds
b528647b
 
d2bf8515
         # Every data update interval request data from the radiation
f555f41f
         # monitor and process the received data.
         if currentTime - lastDataRequestTime > dataRequestInterval:
             lastDataRequestTime = currentTime
207514f1
             dData = {}
b528647b
 
             # Get the data string from the device.
d2bf8515
             result = getRadiationData(dData)
b528647b
 
             # If successful parse the data.
             if result:
d2bf8515
                 result = parseDataString(dData)
b528647b
 
             # If parsing successful, convert the data.
             if result:
0979d8dd
                 result = convertData(dData)
66d7b126
 
207514f1
             # If conversion successful, write data to data files.
b528647b
             if result:
d2bf8515
                 writeOutputFile(dData)
b528647b
 
d2bf8515
             # At the rrdtool database update interval, update the database.
             if result and (currentTime - lastDatabaseUpdateTime > \
                            _DATABASE_UPDATE_INTERVAL):   
                 lastDatabaseUpdateTime = currentTime
                 ## Update the round robin database with the parsed data.
                 result = updateDatabase(dData)
0bfe4f11
 
d2bf8515
             # Set the radmon status to online or offline depending on the
0bfe4f11
             # success or failure of the above operations.
d2bf8515
             setRadmonStatus(result)
0bfe4f11
 
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
d2bf8515
         if verboseMode:
             if result:
                 print("update successful: %6f sec\n"
                       % elapsedTime)
             else:
                 print("update failed: %6f sec\n"
                       % elapsedTime)
f555f41f
         remainingTime = dataRequestInterval - elapsedTime
         if remainingTime > 0.0:
b528647b
             time.sleep(remainingTime)
     ## end while
f555f41f
     return
b528647b
 ## end def
 
 if __name__ == '__main__':
d2bf8515
     main()