nodepower/bin/npwAgent.py
8623d74e
 #!/usr/bin/python2 -u
 # The -u option above turns off block buffering of python output. This 
 # assures that each error message gets individually printed to the log file.
 #
 # Module: nodepowerAgent.py
 #
 # Description: This module acts as an agent between the mesh network and
 # node power and enviromental sensors.  The agent periodically polls the
 # sensors and processes the data returned from the sensors, including
 #     - conversion of data items
 #     - update a round robin (rrdtool) database with the sensor data
 #     - periodically generate graphic charts for display in html documents
 #     - write the processed node status to a JSON file for use by html
 #       documents
 #
 # Copyright 2021 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
 #   * v10 released 01 June 2021 by J L Owrey; first release
3e66b3fa
 #   * v11 released 02 July 2021 by J L Owrey; improved sensor fault
 #     handling; improved code readability
cd727c58
 #   * v12 released 06 July 2021 by J L Owrey; improved debug mode
 #     handling; debug mode state now passed to sensor object constructors
8623d74e
 #
 #2345678901234567890123456789012345678901234567890123456789012345678901234567890
 
 # Import required python libraries.
 import os
 import sys
 import signal
 import subprocess
 import multiprocessing
 import time
b98e8b95
 import json
8623d74e
 
 # Import sensor libraries.
 import ina260 # power sensor
 import tmp102 # temperature sensor
 
b98e8b95
     ### ENVIRONMENT ###
 _USER = os.environ['USER']
 
8623d74e
     ### SENSOR BUS ADDRESSES ###
 
48bd1711
 # Set bus addresses of sensors.
8623d74e
 _PWR_SENSOR_ADDR = 0X40
 _BAT_TMP_SENSOR_ADDR = 0x48
 _AMB_TMP_SENSOR_ADDR = 0x4B
48bd1711
 # Set bus selector.
3e66b3fa
 _BUS_NUMBER = 1
8623d74e
 
     ### FILE AND FOLDER LOCATIONS ###
 
b98e8b95
 # folder to contain html
8623d74e
 _DOCROOT_PATH = "/home/%s/public_html/power/" % _USER
48bd1711
 # folder to contain charts and output data file
8623d74e
 _CHARTS_DIRECTORY = _DOCROOT_PATH + "dynamic/"
48bd1711
 # location of JSON output data file
8623d74e
 _OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/powerData.js"
 # database that stores node data
 _RRD_FILE = "/home/%s/database/powerData.rrd" % _USER
 
     ### GLOBAL CONSTANTS ###
 
 # sensor data request interval in seconds
3e66b3fa
 _DEFAULT_SENSOR_POLLING_INTERVAL = 2
b98e8b95
 # rrdtool database update interval in seconds
 _DATABASE_UPDATE_INTERVAL = 30
3e66b3fa
 # max number of failed attempts to get sensor data
 _MAX_FAILED_DATA_REQUESTS = 2
 
8623d74e
 # chart update interval in seconds
 _CHART_UPDATE_INTERVAL = 600
 # standard chart width in pixels
 _CHART_WIDTH = 600
 # standard chart height in pixels
 _CHART_HEIGHT = 150
d65ec281
 # chart average line color
 _AVERAGE_LINE_COLOR = '#006600'
8623d74e
 
    ### GLOBAL VARIABLES ###
 
3e66b3fa
 # turns on or off extensive debugging messages
98865bbb
 debugMode = False
 verboseMode = False
b98e8b95
 
8623d74e
 # frequency of data requests to sensors
3e66b3fa
 dataRequestInterval = _DEFAULT_SENSOR_POLLING_INTERVAL
48bd1711
 # how often charts get updated
8623d74e
 chartUpdateInterval = _CHART_UPDATE_INTERVAL
3e66b3fa
 # number of failed attempts to get sensor data
 failedUpdateCount = 0
 # sensor status
 deviceOnline = False
8623d74e
 
   ###  PRIVATE METHODS  ###
 
 def getTimeStamp():
     """
b98e8b95
     Get the local time and format as a text string.
8623d74e
     Parameters: none
     Returns: string containing the time stamp
     """
     return time.strftime( "%m/%d/%Y %T", time.localtime() )
d65ec281
 ## end def
8623d74e
 
 def getEpochSeconds(sTime):
b98e8b95
     """
     Convert the time stamp to seconds since 1/1/1970 00:00:00.
     Parameters: 
         sTime - the time stamp to be converted must be formatted
8623d74e
                    as %m/%d/%Y %H:%M:%S
b98e8b95
     Returns: epoch seconds
8623d74e
     """
     try:
         t_sTime = time.strptime(sTime, '%m/%d/%Y %H:%M:%S')
b98e8b95
     except Exception as exError:
         print('%s getEpochSeconds: %s' % (getTimeStamp(), exError))
8623d74e
         return None
     tSeconds = int(time.mktime(t_sTime))
     return tSeconds
d65ec281
 ## end def
8623d74e
 
3e66b3fa
 def setStatusToOffline():
     """Set the detected status of the device to
        "offline" and inform downstream clients by removing input
        and output data files.
        Parameters: none
        Returns: nothing
8623d74e
     """
3e66b3fa
     global deviceOnline
 
8623d74e
     # Inform downstream clients by removing output data file.
     if os.path.exists(_OUTPUT_DATA_FILE):
        os.remove(_OUTPUT_DATA_FILE)
3e66b3fa
     # If the sensor or  device was previously online, then send
     # a message that we are now offline.
     if deviceOnline:
         print('%s device offline' % getTimeStamp())
     deviceOnline = False
 ##end def
 
 def terminateAgentProcess(signal, frame):
     """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
        Returns: nothing
     """
     print('%s terminating agent process' % getTimeStamp())
     setStatusToOffline()
8623d74e
     sys.exit(0)
3e66b3fa
 ##end def
8623d74e
 
   ###  PUBLIC METHODS  ###
 
cd727c58
 def getSensorData(dSensors, dData):
8623d74e
     """
b98e8b95
     Poll sensors for data. Store the data in a dictionary object for
     use by other subroutines.  The dictionary object passed in should
     an empty dictionary, i.e., dData = { }.
     Parameters: dData - a dictionary object to contain the sensor data
cd727c58
                 dSensors - a dictionary containing sensor objects
b98e8b95
     Returns: True if successful, False otherwise
     """
     dData["time"] = getTimeStamp()
  
8623d74e
     try:
cd727c58
         dData["current"] = dSensors['power'].getCurrent()
         dData["voltage"] = dSensors['power'].getVoltage()
         dData["power"] = dSensors['power'].getPower()
         dData["battemp"] = dSensors['battemp'].getTempF()
         dData["ambtemp"] = dSensors['ambtemp'].getTempF()
b98e8b95
     except Exception as exError:
         print("%s sensor error: %s" % (getTimeStamp(), exError))
8623d74e
         return False
 
     return True
d65ec281
 ## end def
8623d74e
 
b98e8b95
 def writeOutputFile(dData):
     """
     Write sensor data items to the output data file, formatted as 
     a Javascript file.  This file may then be requested and used by
     by downstream clients, for instance, an HTML document.
     Parameters:
         dData - a dictionary containing the data to be written
8623d74e
                    to the output data file
b98e8b95
         Returns: True if successful, False otherwise
8623d74e
     """
     # Write a JSON formatted file for use by html clients.  The following
     # data items are sent to the client file.
     #    * The last database update date and time
     #    * The data request interval
     #    * The sensor values
 
     # Create a JSON formatted string from the sensor data.
98865bbb
     jsData = json.loads("{}")
b98e8b95
     try:
         for key in dData:
             jsData.update({key:dData[key]})
         jsData.update({"chartUpdateInterval": chartUpdateInterval})
         sData = "[%s]" % json.dumps(jsData)
     except Exception as exError:
         print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
         return False
 
98865bbb
     if debugMode:
b98e8b95
         print(sData)
8623d74e
 
     # Write the JSON formatted data to the output data file.
b98e8b95
 
8623d74e
     try:
         fc = open(_OUTPUT_DATA_FILE, "w")
         fc.write(sData)
         fc.close()
b98e8b95
     except Exception as exError:
         print("%s write output file failed: %s" % \
               (getTimeStamp(), exError))
8623d74e
         return False
 
     return True
 ## end def
 
3e66b3fa
 def setStatus(updateSuccess):
     """Detect if device is offline or not available on
        the network. After a set number of attempts to get data
        from the device set a flag that the device is offline.
        Parameters:
            updateSuccess - a boolean that is True if data request
                            successful, False otherwise
        Returns: nothing
     """
     global failedUpdateCount, deviceOnline
 
     if updateSuccess:
         failedUpdateCount = 0
         # Set status and send a message to the log if the device
         # previously offline and is now online.
         if not deviceOnline:
             print('%s device online' % getTimeStamp())
             deviceOnline = True
     else:
         # The last attempt failed, so update the failed attempts
         # count.
         failedUpdateCount += 1
 
     if failedUpdateCount >= _MAX_FAILED_DATA_REQUESTS:
         # Max number of failed data requests, so set
         # device status to offline.
         setStatusToOffline()
 ##end def
 
98865bbb
 def updateDatabase(dData):
     """
     Update the rrdtool database by executing an rrdtool system command.
     Format the command using the data extracted from the sensors.
     Parameters: dData - dictionary object containing data items to be
                         written to the rr database file
     Returns: True if successful, False otherwise
     """
  
     epochTime = getEpochSeconds(dData['time'])
 
     # Format the rrdtool update command.
     strFmt = "rrdtool update %s %s:%s:%s:%s:%s:%s"
     strCmd = strFmt % (_RRD_FILE, epochTime, dData['current'], \
              dData['voltage'], dData['power'], dData['battemp'], \
              dData['ambtemp'])
 
     if debugMode:
         print("%s" % strCmd) # DEBUG
 
     # Run the command as a subprocess.
     try:
         subprocess.check_output(strCmd, shell=True, \
             stderr=subprocess.STDOUT)
     except subprocess.CalledProcessError as exError:
         print("%s: rrdtool update failed: %s" % \
             (getTimeStamp(), exError.output))
         return False
 
     if verboseMode and not debugMode:
         print("database updated")
 
     return True
 ## end def
 
8623d74e
 def createGraph(fileName, dataItem, gLabel, gTitle, gStart,
48bd1711
                 lower=0, upper=0, trendLine=0, scaleFactor=1,
                 autoScale=True, alertLine=""):
b98e8b95
     """
     Uses rrdtool to create a graph of specified sensor data item.
     Parameters:
         fileName - name of file containing the graph
         dataItem - data item to be graphed
         gLabel - string containing a graph label for the data item
         gTitle - string containing a title for the graph
         gStart - beginning time of the graphed data
         lower - lower bound for graph ordinate #NOT USED
         upper - upper bound for graph ordinate #NOT USED
         trendLine 
             0, show only graph data
             1, show only a trend line
             2, show a trend line and the graph data
         scaleFactor - amount to pre-scale the data before charting
             the data [default=1]
         autoScale - if True, then use vertical axis auto scaling
             (lower and upper parameters must be zero)
         alertLine - value for which to print a critical
             low voltage alert line on the chart. If not provided
             alert line will not be printed.
     Returns: True if successful, False otherwise
8623d74e
     """
     gPath = _CHARTS_DIRECTORY + fileName + ".png"
     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)
    
     # Set the range and scaling of the chart y-axis.
     if lower < upper:
         strCmd  +=  "-l %s -u %s -r " % (lower, upper)
     elif autoScale:
         strCmd += "-A "
     strCmd += "-Y "
 
     # Set the chart ordinate label and chart title. 
     strCmd += "-v %s -t %s " % (gLabel, gTitle)
  
     # Show the data, or a moving average trend line over
     # the data, or both.
d65ec281
     strCmd += "DEF:rSeries=%s:%s:LAST " % (_RRD_FILE, dataItem)
     strCmd += "CDEF:dSeries=rSeries,%s,/ " % (scaleFactor)
 
     if trendLine == 0:
8623d74e
         strCmd += "LINE1:dSeries#0400ff "
d65ec281
     elif trendLine == 1:
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
                   % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
     elif trendLine == 2:
8623d74e
         strCmd += "LINE1:dSeries#0400ff "
d65ec281
         strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
                   % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
 
     if alertLine != "":
         strCmd += "HRULE:%s#FF0000:Critical\ Low\ Voltage " % (alertLine)
8623d74e
      
98865bbb
     if debugMode:
         print("%s\n" % strCmd) # DEBUG
8623d74e
     
     # Run the formatted rrdtool command as a subprocess.
     try:
         result = subprocess.check_output(strCmd, \
                      stderr=subprocess.STDOUT,   \
                      shell=True)
b98e8b95
     except subprocess.CalledProcessError as exError:
         print("rrdtool graph failed: %s" % (exError.output))
8623d74e
         return False
 
98865bbb
     if verboseMode and not debugMode:
b98e8b95
         print("rrdtool graph: %s" % result.decode('utf-8'))
8623d74e
     return True
 
d65ec281
 ## end def
8623d74e
 
 def generateGraphs():
b98e8b95
     """
     Generate graphs for display in html documents.
     Parameters: none
     Returns: nothing
8623d74e
     """
 
     # 24 hour stock charts
 
d65ec281
     createGraph('24hr_current', 'CUR', 'Amps',
                 'Current\ -\ Last\ 24\ Hours', 'end-1day', \
                 0, 0, 2, 1000)
     createGraph('24hr_voltage', 'VOLT', 'Volts',
                 'Voltage\ -\ Last\ 24\ Hours', 'end-1day', \
                 9, 15, 0, 1, True, 11)
     createGraph('24hr_power', 'PWR', 'Watts', 
                 'Power\ -\ Last\ 24\ Hours', 'end-1day', \
                 0, 0, 2, 1000)
58e58c0d
     createGraph('24hr_battemp', 'BTMP', 'deg\ F', 
8623d74e
                 'Battery\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
d65ec281
                 0, 0, 0)
58e58c0d
     createGraph('24hr_ambtemp', 'ATMP', 'deg\ F', 
8623d74e
                 'Ambient\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
d65ec281
                 0, 0, 0)
8623d74e
 
     # 4 week stock charts
 
d65ec281
     createGraph('4wk_current', 'CUR', 'Amps',
                 'Current\ -\ Last\ 4\ Weeks', 'end-4weeks', \
                 0, 0, 2, 1000)
     createGraph('4wk_voltage', 'VOLT', 'Volts',
                 'Voltage\ -\ Last\ 4\ Weeks', 'end-4weeks', \
                 9, 15, 0, 1, True, 11)
     createGraph('4wk_power', 'PWR', 'Watts', 
                 'Power\ -\ Last\ 4\ Weeks', 'end-4weeks', \
                 0, 0, 2, 1000)
58e58c0d
     createGraph('4wk_battemp', 'BTMP', 'deg\ F', 
8623d74e
                 'Battery\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
d65ec281
                 0, 0, 2)
58e58c0d
     createGraph('4wk_ambtemp', 'ATMP', 'deg\ F', 
8623d74e
                 'Ambient\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
d65ec281
                 0, 0, 2)
8623d74e
 
     # 12 month stock charts
 
d65ec281
     createGraph('12m_current', 'CUR', 'Amps',
                 'Current\ -\ Past\ Year', 'end-12months', \
                 0, 0, 2, 1000)
     createGraph('12m_voltage', 'VOLT', 'Volts',
                 'Voltage\ -\ Past\ Year', 'end-12months', \
                 9, 15, 0, 1, True, 11)
     createGraph('12m_power', 'PWR', 'Watts', 
                 'Power\ -\ Past\ Year', 'end-12months', \
                 0, 0, 2, 1000)
58e58c0d
     createGraph('12m_battemp', 'BTMP', 'deg\ F', 
8623d74e
                 'Battery\ Temperature\ -\ Past\ Year', 'end-12months', \
d65ec281
                 0, 0, 2)
58e58c0d
     createGraph('12m_ambtemp', 'ATMP', 'deg\ F', 
8623d74e
                 'Ambient\ Temperature\ -\ Past\ Year', 'end-12months', \
d65ec281
                 0, 0, 2)
 ## end def
8623d74e
 
 def getCLarguments():
b98e8b95
     """
     Get command line arguments.  There are three possible arguments
         -d turns on debug mode
98865bbb
         -v turns on verbose mode
b98e8b95
         -p sets the sensor query period
         -c sets the chart update period
     Returns: nothing
8623d74e
     """
98865bbb
     global debugMode, verboseMode, dataRequestInterval, chartUpdateInterval
8623d74e
 
     index = 1
     while index < len(sys.argv):
98865bbb
         if sys.argv[index] == '-v':
             verboseMode = True
         elif sys.argv[index] == '-d':
             debugMode = True
             verboseMode = True
8623d74e
         elif sys.argv[index] == '-p':
             try:
                 dataRequestInterval = abs(int(sys.argv[index + 1]))
             except:
b98e8b95
                 print("invalid sensor query period")
48bd1711
                 exit(-1)
             index += 1
         elif sys.argv[index] == '-c':
             try:
                 chartUpdateInterval = abs(int(sys.argv[index + 1]))
             except:
b98e8b95
                 print("invalid chart update period")
8623d74e
                 exit(-1)
             index += 1
         else:
             cmd_name = sys.argv[0].split('/')
b98e8b95
             print("Usage: %s [-d | v] [-p seconds] [-c seconds]" \
                   % cmd_name[-1])
8623d74e
             exit(-1)
         index += 1
 ##end def
 
 def main():
b98e8b95
     """
     Handles timing of events and acts as executive routine managing
     all other functions.
     Parameters: none
     Returns: nothing
8623d74e
     """
     signal.signal(signal.SIGTERM, terminateAgentProcess)
b98e8b95
     signal.signal(signal.SIGINT, terminateAgentProcess)
8623d74e
 
3e66b3fa
     # Log agent process startup time.
     print '===================\n'\
           '%s starting up node power agent process' % \
                   (getTimeStamp())
8623d74e
 
     # last time output JSON file updated
     lastDataRequestTime = -1
     # last time charts generated
     lastChartUpdateTime = - 1
     # last time the rrdtool database updated
     lastDatabaseUpdateTime = -1
 
     ## Get command line arguments.
     getCLarguments()
 
     ## Exit with error if rrdtool database does not exist.
     if not os.path.exists(_RRD_FILE):
b98e8b95
         print('rrdtool database does not exist\n' \
               'use createPowerRrd script to ' \
               'create rrdtool database\n')
8623d74e
         exit(1)
cd727c58
 
     # Create sensor objects.  This also initializes each sensor.
     dSensors = {}
     dSensors['power'] = ina260.ina260(_PWR_SENSOR_ADDR, _BUS_NUMBER,
                             debug=debugMode)
     dSensors['battemp'] = tmp102.tmp102(_BAT_TMP_SENSOR_ADDR, _BUS_NUMBER,
                             debug=debugMode)
     dSensors['ambtemp'] = tmp102.tmp102(_AMB_TMP_SENSOR_ADDR, _BUS_NUMBER,
                             debug=debugMode)
 
     ### MAIN LOOP ###
 
8623d74e
     while True:
 
         currentTime = time.time() # get current time in seconds
 
b98e8b95
         # Every data request interval read the sensors and process the
         # data from the sensors.
8623d74e
         if currentTime - lastDataRequestTime > dataRequestInterval:
             lastDataRequestTime = currentTime
             dData = {}
 
             # Get the data from the sensors.
cd727c58
             result =getSensorData(dSensors, dData)
8623d74e
  
             # If get data successful, write data to data files.
             if result:
b98e8b95
                 result = writeOutputFile(dData)
8623d74e
 
             # At the rrdtool database update interval, update the database.
b98e8b95
             if result and (currentTime - lastDatabaseUpdateTime > \
                            _DATABASE_UPDATE_INTERVAL):   
8623d74e
                 lastDatabaseUpdateTime = currentTime
                 ## Update the round robin database with the parsed data.
b98e8b95
                 result = updateDatabase(dData)
8623d74e
 
3e66b3fa
             setStatus(result)
 
8623d74e
         # At the chart generation interval, generate charts.
         if currentTime - lastChartUpdateTime > chartUpdateInterval:
             lastChartUpdateTime = currentTime
             p = multiprocessing.Process(target=generateGraphs, args=())
             p.start()
d65ec281
             
8623d74e
         # Relinquish processing back to the operating system until
         # the next update interval.
 
         elapsedTime = time.time() - currentTime
98865bbb
         if verboseMode:
8623d74e
             if result:
b98e8b95
                 print("update successful: %6f sec\n"
                       % elapsedTime)
8623d74e
             else:
b98e8b95
                 print("update failed: %6f sec\n"
                       % elapsedTime)
8623d74e
         remainingTime = dataRequestInterval - elapsedTime
         if remainingTime > 0.0:
             time.sleep(remainingTime)
     ## end while
     return
 ## end def
 
 if __name__ == '__main__':
b98e8b95
     main()