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 ###
|
fda32706 |
# Container for sensor objects.
dSensors = {}
|
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 ###
|
fda32706 |
def getSensorData(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
|
1f215da7 |
dData['chartUpdateInterval'] = chartUpdateInterval
|
8623d74e |
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]})
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
|
1f215da7 |
return
|
3e66b3fa |
else:
# The last attempt failed, so update the failed attempts
# count.
failedUpdateCount += 1
|
1f215da7 |
if failedUpdateCount == _MAX_FAILED_DATA_REQUESTS:
|
3e66b3fa |
# Max number of failed data requests, so set
# device status to offline.
setStatusToOffline()
##end def
|
1f215da7 |
### DATABASE FUNCTIONS ###
|
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:
|
1f215da7 |
print("%s: rrdtool update: %s" % \
|
98865bbb |
(getTimeStamp(), exError.output))
return False
if verboseMode and not debugMode:
|
1f215da7 |
print("database update successful")
|
98865bbb |
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:
|
1f215da7 |
print("%s rrdtool graph: %s" % \
(getTimeStamp(), 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 |
## 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['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)
|
fda32706 |
# Enter main loop.
main_loop()
## end def
def main_loop():
# last time output JSON file updated
lastDataRequestTime = -1
# last time charts generated
lastChartUpdateTime = - 1
# last time the rrdtool database updated
lastDatabaseUpdateTime = -1
|
cd727c58 |
### 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.
|
fda32706 |
result =getSensorData(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
## end def
if __name__ == '__main__':
|
b98e8b95 |
main()
|