b9676d21 |
#!/usr/bin/python3 -u
|
11c3de66 |
# 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: arednsigAgent.py
#
# Description: This module acts as an agent between the aredn node
# and aredn mest services. The agent periodically sends an http
# request to the aredn node, processes the response from
# the node, and performs a number of operations:
# - conversion of data items
# - update a round robin (rrdtool) database with the node 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 2020 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
# * v20 released 11 Jan 2020 by J L Owrey; first release
# * v21 released 13 Feb 2020 by J L Owrey; fixed bug occuring when node
# powers on and signal data memory is empty. Data points with N/A data
# are discarded.
# * v22 released 31 Mar 2020 by J L Owrey; upgraded for compatibility with
# Aredn firmware version 3.20.3.0. This agent now downloads the node's
# status page and parsed the signal data from the html.
|
b9676d21 |
# * v23 released 11 Jun 2021 by J L Owrey; remove unused code.
# * v24 released 14 Jun 2021 by J L Owrey; minor revisions
|
cd714b43 |
# * v25 released 9 Jul 2021 by J L Owrey; improved handling of
# node status function
|
11c3de66 |
#
#2345678901234567890123456789012345678901234567890123456789012345678901234567890
import os
import sys
import signal
import multiprocessing
import time
|
b9676d21 |
import json
from urllib.request import urlopen
|
b6672deb |
import rrdbase
|
b9676d21 |
### ENVIRONMENT ###
|
11c3de66 |
_USER = os.environ['USER']
|
b9676d21 |
_SERVER_MODE = "primary"
|
11c3de66 |
### DEFAULT AREDN NODE URL ###
|
b6672deb |
_DEFAULT_AREDN_NODE_URL = "http://localnode.local.mesh/cgi-bin/status"
|
11c3de66 |
### FILE AND FOLDER LOCATIONS ###
# folder for containing dynamic data objects
_DOCROOT_PATH = "/home/%s/public_html/arednsig/" % _USER
# folder for charts and output data file
_CHARTS_DIRECTORY = _DOCROOT_PATH + "dynamic/"
# location of data output file
|
b9676d21 |
_OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/arednsigData.js"
|
11c3de66 |
# database that stores node data
_RRD_FILE = "/home/%s/database/arednsigData.rrd" % _USER
### GLOBAL CONSTANTS ###
|
b6672deb |
# maximum number of failed data requests allowed
|
b9676d21 |
_MAX_FAILED_DATA_REQUESTS = 2
|
b6672deb |
# maximum number of http request retries allowed
_MAX_HTTP_RETRIES = 3
# delay time between http request retries
_HTTP_RETRY_DELAY = 1.1199
# interval in seconds between data requests
|
11c3de66 |
_DEFAULT_DATA_REQUEST_INTERVAL = 60
|
b9676d21 |
# number seconds to wait for a response to HTTP request
|
b6672deb |
_HTTP_REQUEST_TIMEOUT = 3
|
b9676d21 |
|
b6672deb |
# interval in seconds between database updates
_DATABASE_UPDATE_INTERVAL = 60
|
fe97b8b6 |
# chart update interval in seconds
|
11c3de66 |
_CHART_UPDATE_INTERVAL = 600
# standard chart width in pixels
_CHART_WIDTH = 600
# standard chart height in pixels
_CHART_HEIGHT = 150
# Set this to True only if this server is intended to relay raw
### GLOBAL VARIABLES ###
# turn on or off of verbose debugging information
|
18b86ce9 |
verboseMode = False
debugMode = False
|
b6672deb |
reportUpdateFails = False
|
11c3de66 |
# The following two items are used for detecting system faults
# and aredn node online or offline status.
# count of failed attempts to get data from aredn node
failedUpdateCount = 0
|
b6672deb |
httpRetries = 0
|
cd714b43 |
nodeOnline = False
|
11c3de66 |
# ip address of aredn node
arednNodeUrl = _DEFAULT_AREDN_NODE_URL
# frequency of data requests to aredn node
dataRequestInterval = _DEFAULT_DATA_REQUEST_INTERVAL
# chart update interval
chartUpdateInterval = _CHART_UPDATE_INTERVAL
|
b6672deb |
# rrdtool database interface handler instance
rrdb = None
|
11c3de66 |
### PRIVATE METHODS ###
def getTimeStamp():
"""
Set the error message time stamp to the local system time.
Parameters: none
Returns: string containing the time stamp
"""
return time.strftime( "%m/%d/%Y %T", time.localtime() )
##end def
def setStatusToOffline():
"""Set the detected status of the aredn node to
"offline" and inform downstream clients by removing input
and output data files.
Parameters: none
Returns: nothing
"""
global nodeOnline
# Inform downstream clients by removing output data file.
if os.path.exists(_OUTPUT_DATA_FILE):
os.remove(_OUTPUT_DATA_FILE)
# If the aredn node was previously online, then send
# a message that we are now offline.
if nodeOnline:
|
b9676d21 |
print('%s aredn node offline' % getTimeStamp())
|
11c3de66 |
nodeOnline = 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
"""
|
b6672deb |
# Inform downstream clients by removing output data file.
if os.path.exists(_OUTPUT_DATA_FILE):
os.remove(_OUTPUT_DATA_FILE)
|
b9676d21 |
print('%s terminating arednsig agent process' % getTimeStamp())
|
11c3de66 |
sys.exit(0)
##end def
### PUBLIC METHODS ###
|
18b86ce9 |
def getNodeData(dData):
|
11c3de66 |
"""Send http request to aredn node. The response from the
node contains the node signal data as unformatted ascii text.
Parameters: none
|
18b86ce9 |
Returns: True if successful,
or False if not successful
|
11c3de66 |
"""
|
b6672deb |
global httpRetries
|
11c3de66 |
try:
|
b9676d21 |
currentTime = time.time()
response = urlopen(arednNodeUrl, timeout=_HTTP_REQUEST_TIMEOUT)
|
cd714b43 |
requestTime = time.time() - currentTime
|
b9676d21 |
content = response.read().decode('utf-8')
content = content.replace('\n', '')
content = content.replace('\r', '')
if content == "":
raise Exception("empty response")
except Exception as exError:
|
11c3de66 |
# If no response is received from the device, then assume that
# the device is down or unavailable over the network. In
# that case return None to the calling function.
|
b6672deb |
httpRetries += 1
if reportUpdateFails:
print("%s " % getTimeStamp(), end='')
if reportUpdateFails or verboseMode:
print("http request failed (%d): %s" % \
(httpRetries, exError))
if httpRetries > _MAX_HTTP_RETRIES:
httpRetries = 0
return False
else:
time.sleep(_HTTP_RETRY_DELAY)
return getNodeData(dData)
## end try
|
11c3de66 |
|
18b86ce9 |
if debugMode:
|
b9676d21 |
print(content)
|
cd714b43 |
if verboseMode:
|
b6672deb |
print("http request successful: "\
"%.4f seconds" % requestTime)
|
18b86ce9 |
|
b6672deb |
httpRetries = 0
|
18b86ce9 |
dData['content'] = content
return True
|
11c3de66 |
##end def
|
18b86ce9 |
def parseDataString(dData):
|
b9676d21 |
"""Parse the node signal data JSON string from the aredn node
|
11c3de66 |
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
"""
|
18b86ce9 |
sData = dData.pop('content')
|
11c3de66 |
try:
strBeginSearch = '<nobr>Signal/Noise/Ratio</nobr></th>' \
'<td valign=middle><nobr><big><b>'
|
b9676d21 |
strEndSearch = 'dB</b>'
|
11c3de66 |
iBeginIndex = sData.find(strBeginSearch) + len(strBeginSearch)
iEndIndex = sData.find(strEndSearch, iBeginIndex)
|
b9676d21 |
#print("search params: %d, %d" % (iBeginIndex, iEndIndex))
|
11c3de66 |
if iBeginIndex == -1 or iEndIndex == -1:
raise Exception("signal data not found in status page")
snr = sData[iBeginIndex:iEndIndex]
snr = snr.replace(' ','')
lsnr = snr.split('/')
dData['signal'] = lsnr[0]
dData['noise'] = lsnr[1]
dData['snr'] = lsnr[2]
|
b9676d21 |
except Exception as exError:
print("%s parse failed: %s" % (getTimeStamp(), exError))
|
11c3de66 |
return False
|
cd714b43 |
## end try
# Add status information to dictionary object.
dData['date'] = getTimeStamp()
dData['chartUpdateInterval'] = chartUpdateInterval
dData['dataRequestInterval'] = dataRequestInterval
dData['serverMode'] = _SERVER_MODE
|
11c3de66 |
return True
##end def
|
b9676d21 |
def writeOutputFile(dData):
|
11c3de66 |
"""Write node data items to the output data file, formatted as
a Javascript file. This file may then be accessed and used by
by downstream clients, for instance, in HTML documents.
Parameters:
sData - a string object containing the data to be written
to the output data file
Returns: True if successful, False otherwise
"""
# Write file for use by html clients. The following two
# data items are sent to the client file.
# * The last database update date and time
# * The data request interval
|
b9676d21 |
# Format data into a JSON string.
|
18b86ce9 |
jsData = json.loads("{}")
|
11c3de66 |
try:
|
cd714b43 |
for key in dData:
jsData.update({key:dData[key]})
|
b9676d21 |
sData = "[%s]" % json.dumps(jsData)
except Exception as exError:
print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
|
11c3de66 |
return False
|
18b86ce9 |
if debugMode:
|
b9676d21 |
print(sData)
try:
fc = open(_OUTPUT_DATA_FILE, "w")
fc.write(sData)
fc.close()
except Exception as exError:
print("%s write output file failed: %s" % (getTimeStamp(), exError))
return False
|
11c3de66 |
return True
## end def
|
cd714b43 |
def setNodeStatus(updateSuccess):
"""Detect if aredn node is offline or not available on
the network. After a set number of attempts to get data
from the node set a flag that the node is offline.
Parameters:
updateSuccess - a boolean that is True if data request
successful, False otherwise
Returns: nothing
"""
global failedUpdateCount, nodeOnline
if updateSuccess:
failedUpdateCount = 0
# Set status and send a message to the log if the device
# previously offline and is now online.
if not nodeOnline:
|
b6672deb |
print('%s aredn node online' % getTimeStamp())
|
cd714b43 |
nodeOnline = True
return
|
b6672deb |
else:
# The last attempt failed, so update the failed attempts
# count.
failedUpdateCount += 1
if failedUpdateCount == _MAX_FAILED_DATA_REQUESTS:
|
cd714b43 |
# Max number of failed data requests, so set
# device status to offline.
setStatusToOffline()
##end def
|
b6672deb |
### GRAPH FUNCTIONS ###
|
11c3de66 |
def generateGraphs():
"""Generate graphs for display in html documents.
Parameters: none
Returns: nothing
"""
autoScale = False
# The following will force creation of charts
# of only signal strength and S/N charts. Note that the following
# data items appear constant and do not show variation with time:
# noise level, rx mcs, rx rate, tx mcs, tx rate. Therefore, until
# these parameters are demonstrated to vary in time, there is no point
# in creating the charts for these data items.
createAllCharts = False
# 24 hour stock charts
|
b6672deb |
#### REPLACE WITH createRadGraph ####
rrdb.createAutoGraph('24hr_signal', 'S', 'dBm',
|
11c3de66 |
'RSSI\ -\ Last\ 24\ Hours', 'end-1day', 0, 0, 2, autoScale)
|
b6672deb |
rrdb.createAutoGraph('24hr_snr', 'SNR', 'dB',
|
11c3de66 |
'SNR\ -\ Last\ 24\ Hours', 'end-1day', 0, 0, 2, autoScale)
# 4 week stock charts
|
b6672deb |
rrdb.createAutoGraph('4wk_signal', 'S', 'dBm',
|
11c3de66 |
'RSSI\ -\ Last\ 4\ Weeks', 'end-4weeks', 0, 0, 2, autoScale)
|
b6672deb |
rrdb.createAutoGraph('4wk_snr', 'SNR', 'dB',
|
11c3de66 |
'SNR\ -\ Last\ 4\ Weeks', 'end-4weeks', 0, 0, 2, autoScale)
# 12 month stock charts
|
b6672deb |
rrdb.createAutoGraph('12m_signal', 'S', 'dBm',
|
11c3de66 |
'RSSI\ -\ Past\ Year', 'end-12months', 0, 0, 2, autoScale)
|
b6672deb |
rrdb.createAutoGraph('12m_snr', 'SNR', 'dB',
|
11c3de66 |
'SNR\ -\ Past\ Year', 'end-12months', 0, 0, 2, autoScale)
|
18b86ce9 |
if verboseMode:
|
b9676d21 |
#print() # print a blank line to improve readability when in debug mode
|
11c3de66 |
pass
##end def
def getCLarguments():
"""Get command line arguments. There are four possible arguments
-d turns on debug mode
-v turns on verbose debug mode
|
b6672deb |
-p sets the aredn node query interval
|
11c3de66 |
-u sets the url of the aredn nodeing device
Returns: nothing
"""
|
18b86ce9 |
global verboseMode, debugMode, dataRequestInterval, \
|
b6672deb |
arednNodeUrl, reportUpdateFails
|
11c3de66 |
index = 1
while index < len(sys.argv):
|
18b86ce9 |
if sys.argv[index] == '-v':
verboseMode = True
elif sys.argv[index] == '-d':
verboseMode = True
debugMode = True
|
b6672deb |
elif sys.argv[index] == '-r':
reportUpdateFails = True
# Update period and url options
|
11c3de66 |
elif sys.argv[index] == '-p':
try:
|
b6672deb |
dataRequestInterval = abs(float(sys.argv[index + 1]))
|
11c3de66 |
except:
|
b9676d21 |
print("invalid polling period")
|
11c3de66 |
exit(-1)
index += 1
elif sys.argv[index] == '-u':
arednNodeUrl = sys.argv[index + 1]
|
b9676d21 |
if arednNodeUrl.find('http://') < 0:
arednNodeUrl = 'http://' + arednNodeUrl
|
11c3de66 |
index += 1
else:
cmd_name = sys.argv[0].split('/')
|
b6672deb |
print("Usage: %s [-v|d] [-p seconds] [-u url]" % cmd_name[-1])
|
11c3de66 |
exit(-1)
index += 1
##end def
|
b6672deb |
def setup():
|
11c3de66 |
"""Handles timing of events and acts as executive routine managing
all other functions.
Parameters: none
Returns: nothing
"""
|
b6672deb |
global rrdb
|
11c3de66 |
|
b6672deb |
## Get command line arguments.
getCLarguments()
|
11c3de66 |
|
b6672deb |
print('======================================================')
|
b9676d21 |
print('%s starting up arednsig agent process' % \
(getTimeStamp()))
|
11c3de66 |
## Exit with error if rrdtool database does not exist.
if not os.path.exists(_RRD_FILE):
|
b9676d21 |
print('rrdtool database does not exist\n' \
|
11c3de66 |
'use createArednsigRrd script to ' \
|
b9676d21 |
'create rrdtool database\n')
|
11c3de66 |
exit(1)
|
b6672deb |
signal.signal(signal.SIGTERM, terminateAgentProcess)
signal.signal(signal.SIGINT, terminateAgentProcess)
# Define object for calling rrdtool database functions.
rrdb = rrdbase.rrdbase( _RRD_FILE, _CHARTS_DIRECTORY, _CHART_WIDTH, \
_CHART_HEIGHT, verboseMode, debugMode )
## end def
def loop():
# last time output JSON file updated
lastDataRequestTime = -1
# last time charts generated
lastChartUpdateTime = -1
# last time the rrdtool database updated
lastDatabaseUpdateTime = -1
|
11c3de66 |
## main loop
while True:
currentTime = time.time() # get current time in seconds
# Every web update interval request data from the aredn
# node and process the received data.
if currentTime - lastDataRequestTime > dataRequestInterval:
lastDataRequestTime = currentTime
dData = {}
# Get the data string from the device.
|
18b86ce9 |
result = getNodeData(dData)
|
b9676d21 |
|
11c3de66 |
# If successful parse the data.
if result:
|
18b86ce9 |
result = parseDataString(dData)
|
b9676d21 |
# If parse successful, write data output data file.
|
11c3de66 |
if result:
|
b9676d21 |
writeOutputFile(dData)
|
11c3de66 |
|
b6672deb |
# At the rrdtool database update interval, update the database.
if result and (currentTime - lastDatabaseUpdateTime > \
_DATABASE_UPDATE_INTERVAL):
lastDatabaseUpdateTime = currentTime
# If write output file successful, update the database.
if result:
result = rrdb.updateDatabase(dData['date'], \
dData['signal'], dData['noise'], dData['snr'], \
'0', '0', '0', '0')
|
11c3de66 |
# Set the node status to online or offline depending on the
# success or failure of the above operations.
setNodeStatus(result)
# At the chart generation interval, generate charts.
if currentTime - lastChartUpdateTime > chartUpdateInterval:
lastChartUpdateTime = currentTime
p = multiprocessing.Process(target=generateGraphs, args=())
|
b6672deb |
p.start()
|
11c3de66 |
# Relinquish processing back to the operating system until
# the next update interval.
elapsedTime = time.time() - currentTime
|
18b86ce9 |
if verboseMode:
|
11c3de66 |
if result:
|
cd714b43 |
print("update successful: %s sec\n" % elapsedTime)
|
11c3de66 |
else:
|
cd714b43 |
print("update failed: %s sec\n" % elapsedTime)
|
11c3de66 |
remainingTime = dataRequestInterval - elapsedTime
if remainingTime > 0.0:
time.sleep(remainingTime)
## end while
## end def
if __name__ == '__main__':
|
b6672deb |
setup()
loop()
|
b9676d21 |
|