Browse code

seperate folders for python2, python3, and i2cmux scripts

gandolf authored on 12/02/2022 01:31:01
Showing 22 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,93 @@
1
+#!/usr/bin/python3 -u
2
+## The -u option above turns off block buffering of python output. This assures
3
+## that each error message gets individually printed to the log file.
4
+#
5
+# Module: createPowerRrd.py
6
+#
7
+# Description: Creates a rrdtool database for use by the power agent to
8
+# store the data from the power and temperature sensors.  The agent uses
9
+# the data in the database to generate graphic charts for display in the
10
+# weather station web page.
11
+#
12
+# Copyright 2021 Jeff Owrey
13
+#    This program is free software: you can redistribute it and/or modify
14
+#    it under the terms of the GNU General Public License as published by
15
+#    the Free Software Foundation, either version 3 of the License, or
16
+#    (at your option) any later version.
17
+#
18
+#    This program is distributed in the hope that it will be useful,
19
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
20
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
+#    GNU General Public License for more details.
22
+#
23
+#    You should have received a copy of the GNU General Public License
24
+#    along with this program.  If not, see http://www.gnu.org/license.
25
+#
26
+# Revision History
27
+#   * v10 released 01 Jun 2021 by J L Owrey
28
+#
29
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
30
+
31
+import os
32
+import time
33
+import subprocess
34
+
35
+    ### DEFINE DATABASE FILE LOCATION ###
36
+
37
+_USER = os.environ['USER']
38
+# the file that stores the data
39
+_RRD_FILE = "/home/%s/database/powerData.rrd" % _USER
40
+
41
+   ### DEFINE DATABASE SIZE AND GRANULARITY
42
+
43
+_RRD_SIZE_IN_DAYS = 740 # days
44
+_1YR_RRA_STEPS_PER_DAY = 1440
45
+_DATABASE_UPDATE_INTERVAL = 30
46
+
47
+def createRrdFile():
48
+    """Create the rrd file if it does not exist.
49
+       Parameters: none
50
+       Returns: True, if successful
51
+    """
52
+
53
+    if os.path.exists(_RRD_FILE):
54
+        print("power database already exists")
55
+        return True
56
+
57
+     ## Calculate database size
58
+ 
59
+    heartBeat = 2 * _DATABASE_UPDATE_INTERVAL
60
+    rra1yrNumPDP =  int(round(86400 / (_1YR_RRA_STEPS_PER_DAY * \
61
+                    _DATABASE_UPDATE_INTERVAL)))
62
+    rrd24hrNumRows = int(round(86400 / _DATABASE_UPDATE_INTERVAL))
63
+    rrd1yearNumRows = _1YR_RRA_STEPS_PER_DAY * _RRD_SIZE_IN_DAYS
64
+       
65
+    strFmt = ("rrdtool create %s --start now-1day --step %s "
66
+              "DS:CUR:GAUGE:%s:U:U DS:VOLT:GAUGE:%s:U:U "
67
+              "DS:PWR:GAUGE:%s:U:U DS:BTMP:GAUGE:%s:U:U "
68
+              "DS:ATMP:GAUGE:%s:U:U "
69
+              "RRA:LAST:0.5:1:%s RRA:LAST:0.5:%s:%s")
70
+
71
+    strCmd = strFmt % (_RRD_FILE, _DATABASE_UPDATE_INTERVAL,           \
72
+                heartBeat, heartBeat, heartBeat, heartBeat, heartBeat, \
73
+                rrd24hrNumRows, rra1yrNumPDP, rrd1yearNumRows)
74
+
75
+    print("creating power database...\n\n%s\n" % strCmd)
76
+
77
+    # Spawn a sub-shell and run the command
78
+    try:
79
+        subprocess.check_output(strCmd, stderr=subprocess.STDOUT, \
80
+                                shell=True)
81
+    except subprocess.CalledProcessError as exError:
82
+        print("rrdtool create failed: %s" % (exError.output))
83
+        return False
84
+    return True
85
+##end def
86
+
87
+def main():
88
+    createRrdFile()
89
+## end def
90
+
91
+if __name__ == '__main__':
92
+    main()
93
+        
0 94
new file mode 100755
... ...
@@ -0,0 +1,147 @@
1
+#!/usr/bin/python3
2
+#
3
+# Module: tmp102.py
4
+#
5
+# Description: This module acts as an interface between the TCA9548A i2c mux
6
+# and connected devices.  Class methods send and receive data from specific
7
+# devices connected to the mux. It acts as a library module that
8
+# can be imported into and called from other Python programs.
9
+#
10
+# Copyright 2022 Jeff Owrey
11
+#    This program is free software: you can redistribute it and/or modify
12
+#    it under the terms of the GNU General Public License as published by
13
+#    the Free Software Foundation, either version 3 of the License, or
14
+#    (at your option) any later version.
15
+#
16
+#    This program is distributed in the hope that it will be useful,
17
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19
+#    GNU General Public License for more details.
20
+#
21
+#    You should have received a copy of the GNU General Public License
22
+#    along with this program.  If not, see http://www.gnu.org/license.
23
+#
24
+# Revision History
25
+#   * v10 released 16 March 2022 by J L Owrey; first release
26
+#
27
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
28
+
29
+# Import the I2C interface library
30
+import smbus
31
+import time
32
+
33
+# Define default sm bus address.
34
+DEFAULT_BUS_ADDRESS = 0x70
35
+DEFAULT_BUS_NUMBER = 1
36
+
37
+# Define the default mux configuration.  See the TCA9548A data sheet
38
+# for meaning of each bit.  The following byte is written to the
39
+# configuration register.  This byte disables all mux outputs.
40
+#     byte 1: 00000000
41
+DEFAULT_CONFIG = 8
42
+
43
+channel_conf=[0b00000001,0b00000010,0b00000100,0b00001000,0b00010000, \
44
+              0b00100000,0b01000000,0b10000000,0]
45
+
46
+class i2cmux:
47
+
48
+    # Initialize the TCA9548A multiplexer at the supplied address
49
+    # (default address is 0x70), and supplied bus (default is 1).
50
+    # Creates a new SMBus object for each instance of this class.
51
+    # Writes configuration data (one byte) to the TCA9548A configuration
52
+    # register.
53
+    def __init__(self, mAddr=DEFAULT_BUS_ADDRESS,
54
+                 sbus=DEFAULT_BUS_NUMBER,
55
+                 config=DEFAULT_CONFIG,
56
+                 debug=False): 
57
+        # Instantiate a smbus object
58
+        self.muxAddr = mAddr
59
+        self.bus = smbus.SMBus(sbus)
60
+        self.debugMode = debug
61
+
62
+        # Initialize mux with all channels disabled.  
63
+        self.bus.write_byte(self.muxAddr, channel_conf[config])
64
+
65
+        if self.debugMode:
66
+            # Read the mux configuration register.
67
+            data = self.getInfo()
68
+            print("mux config register: %s\n" % data)
69
+    ## end def
70
+
71
+    # Reads the configuration register (two bytes).
72
+    def getInfo(self):
73
+        # Read configuration data
74
+        config = self.bus.read_byte(self.muxAddr)
75
+        configB = format(config, "08b")
76
+        return (configB)
77
+    ## end def
78
+
79
+    def write_i2c_block_data(self, channel, addr, offset, data):
80
+        # Write block data to the device connected to the specified
81
+        # channel.
82
+        # Enable the specified mux channel.  
83
+        self.bus.write_byte(self.muxAddr, channel_conf[channel])
84
+        time.sleep(.001)
85
+        # Write data to the device connected to the channel.     
86
+        self.bus.write_i2c_block_data(addr, offset, data)
87
+        time.sleep(.001)
88
+        # Disable the mux channel.
89
+        self.bus.write_byte(self.muxAddr, 0)
90
+    ## end def
91
+
92
+    def read_i2c_block_data(self, channel, addr, offset, nBytes):
93
+        # Read block data from the device connected to the specified
94
+        # channel.
95
+
96
+        # Enable the specified mux channel.  
97
+        self.bus.write_byte(self.muxAddr, channel_conf[channel])
98
+        time.sleep(.001)
99
+        # Write data to the device connected to the channel.     
100
+        data = self.bus.read_i2c_block_data(addr, offset, nBytes)
101
+        time.sleep(.001)
102
+        # Disable the mux channel.
103
+        self.bus.write_byte(self.muxAddr, 0)
104
+        return data
105
+    ## end def
106
+
107
+    def write_byte(self, channel, addr, byte):
108
+        # Write a byte to the device connected to the specified
109
+        # channel.
110
+
111
+        # Enable the specified mux channel.  
112
+        self.bus.write_byte(self.muxAddr, channel_conf[channel])
113
+        time.sleep(.001)
114
+        # Write data to the device connected to the channel.     
115
+        self.bus.write_byte(addr, byte)
116
+        time.sleep(.001)
117
+        # Disable the mux channel.
118
+        self.bus.write_byte(self.muxAddr, 0)
119
+    ## end def
120
+
121
+    def read_byte(self, channel, addr):
122
+        # Read a byte from the device connected to the specified
123
+        # channel.
124
+
125
+        # Enable the specified mux channel.  
126
+        self.bus.write_byte(self.muxAddr, channel_conf[channel])
127
+        time.sleep(.001)
128
+        # Read data from the device connected to the channel.     
129
+        byte = self.bus.read_byte(addr)
130
+        time.sleep(.001)
131
+        # Disable the mux channel.
132
+        self.bus.write_byte(self.muxAddr, 0)
133
+        return byte
134
+    ## end def
135
+
136
+## end class
137
+
138
+def testclass():
139
+    # Initialize the smbus and TMP102 sensor.
140
+    ts1 = i2cmux(0x70, 1, config=0, debug=True)
141
+    ts2 = i2cmux(debug=True)
142
+    del ts1, ts2
143
+## end def
144
+
145
+if __name__ == '__main__':
146
+    testclass()
147
+
0 148
new file mode 100755
... ...
@@ -0,0 +1,214 @@
1
+#!/usr/bin/python3
2
+#
3
+# Module: ina260.py
4
+#
5
+# Description: This module acts as an interface between the INA260 sensor
6
+# and downstream applications that use the data.  Class methods get
7
+# current, voltage, and power data from the INA260 sensor.  It acts as a
8
+# library module that can be imported into and called from other Python
9
+# programs.
10
+#
11
+# Copyright 2022 Jeff Owrey
12
+#    This program is free software: you can redistribute it and/or modify
13
+#    it under the terms of the GNU General Public License as published by
14
+#    the Free Software Foundation, either version 3 of the License, or
15
+#    (at your option) any later version.
16
+#
17
+#    This program is distributed in the hope that it will be useful,
18
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
19
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20
+#    GNU General Public License for more details.
21
+#
22
+#    You should have received a copy of the GNU General Public License
23
+#    along with this program.  If not, see http://www.gnu.org/license.
24
+#
25
+# Revision History
26
+#   * v10 released 01 June 2021 by J L Owrey; first release
27
+#   * v20 released 16 March 2022 by J L Owrey; major revision to add
28
+#     fuctionality allowing i2c serial devices to use i2c serial bus
29
+#     multiplexer.  Also upgraded to python 3. 
30
+#
31
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
32
+
33
+# Import the I2C interface library
34
+import i2cmux
35
+
36
+# Define Device Registers
37
+CONFIG_REG = 0x0
38
+ID_REG = 0xFE
39
+CUR_REG = 0x1
40
+VOLT_REG = 0x2
41
+PWR_REG = 0x3
42
+
43
+# Define default sm bus address.
44
+DEFAULT_MUX_CHANNEL = 0
45
+DEFAULT_BUS_ADDRESS = 0x40
46
+DEFAULT_BUS_NUMBER = 1
47
+
48
+# Define the default sensor configuration.  See the INA260 data sheet
49
+# for meaning of each bit.  The following bytes are written to the
50
+# configuration register
51
+#     byte 1: 11100000
52
+#     byte 2: 00100111
53
+DEFAULT_CONFIG = 0xE027
54
+
55
+class ina260:
56
+    # Initialize the INA260 sensor at the supplied address (default
57
+    # address is 0x40), and supplied bus (default is 1).  Creates
58
+    # a new SMBus object for each instance of this class.  Writes
59
+    # configuration data (two bytes) to the INA260 configuration
60
+    # register.
61
+    def __init__(self, objMux, sAddr=DEFAULT_BUS_ADDRESS,
62
+                       chan=DEFAULT_MUX_CHANNEL,
63
+                       config=DEFAULT_CONFIG,
64
+                       debug=False):
65
+        # Instantiate a smbus object.
66
+        self.mux = objMux
67
+        self.sensorAddr = sAddr
68
+        self.channel = chan
69
+        self.debugMode = debug
70
+
71
+        # Initialize INA260 sensor.  
72
+        initData = [(config >> 8), (config & 0x00FF)]
73
+        self.mux.write_i2c_block_data(self.channel, self.sensorAddr, \
74
+                CONFIG_REG, initData)
75
+
76
+        if self.debugMode:
77
+            data = self.getInfo()
78
+            print(self)
79
+            print("manufacturer ID: %s %s\n"\
80
+                  "INA260 configuration register: %s %s\n" % data)
81
+    ## end def
82
+
83
+    def getInfo(self):
84
+        # Read manufacture identification data.
85
+        mfcid = self.mux.read_i2c_block_data(self.channel, \
86
+                self.sensorAddr, ID_REG, 2)
87
+        mfcidB1 = format(mfcid[0], "08b")
88
+        mfcidB2 = format(mfcid[1], "08b")
89
+        # Read configuration data.
90
+        config = self.mux.read_i2c_block_data(self.channel, \
91
+                 self.sensorAddr, CONFIG_REG, 2)
92
+        configB1 = format(config[0], "08b")
93
+        configB2 = format(config[1], "08b")
94
+        return (mfcidB1, mfcidB2, configB1, configB2)
95
+    ## end def
96
+
97
+    def getCurrentReg(self):
98
+        # Read current register and return raw binary data for test and
99
+        # debug.
100
+        data = self.mux.read_i2c_block_data(self.channel, \
101
+               self.sensorAddr, CUR_REG, 2)
102
+        dataB1 = format(data[0], "08b")
103
+        dataB2 = format(data[1], "08b")
104
+        return (dataB1, dataB2)
105
+    ## end def
106
+
107
+    def getCurrent(self):
108
+        # Get the current data from the sensor.
109
+        # INA260 returns the data in two bytes formatted as follows
110
+        #        -------------------------------------------------
111
+        #    bit |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
112
+        #        -------------------------------------------------
113
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
114
+        #        -------------------------------------------------
115
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
116
+        #        -------------------------------------------------
117
+        # The current is returned in d15-d0, a two's complement,
118
+        # 16 bit number.  This means that d15 is the sign bit.        
119
+        data=self.mux.read_i2c_block_data(self.channel, \
120
+             self.sensorAddr, CUR_REG, 2)
121
+
122
+        if self.debugMode:
123
+            dataB1 = format(data[0], "08b")
124
+            dataB2 = format(data[1], "08b")
125
+            print("current register: %s %s" % (dataB1, dataB2))
126
+
127
+        # Format into a 16 bit word.
128
+        bdata = data[0] << 8 | data[1]
129
+        # Convert from two's complement to integer.
130
+        # If d15 is 1, the the number is a negative two's complement
131
+        # number.  The absolute value is 2^16 - 1 minus the value
132
+        # of d15-d0 taken as a positive number.
133
+        if bdata > 0x7FFF:
134
+            bdata = -(0xFFFF - bdata) # 0xFFFF equals 2^16 - 1
135
+        # Convert integer data to mAmps.
136
+        mAmps = bdata * 1.25  # LSB is 1.25 mA
137
+        return mAmps
138
+    ## end def
139
+
140
+    def getVoltageReg(self):
141
+        # Read voltage register and return raw binary data for test
142
+        # and debug.
143
+        data = self.mux.read_i2c_block_data(self.sensorAddr, VOLT_REG, 2)
144
+        dataB1 = format(data[0], "08b")
145
+        dataB2 = format(data[1], "08b")
146
+        return (dataB1, dataB2)
147
+    ## end def
148
+
149
+    def getVoltage(self):
150
+        # Get the voltage data from the sensor.
151
+        # INA260 returns the data in two bytes formatted as follows
152
+        #        -------------------------------------------------
153
+        #    bit |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
154
+        #        -------------------------------------------------
155
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
156
+        #        -------------------------------------------------
157
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
158
+        #        -------------------------------------------------
159
+        # The voltage is returned in d15-d0 as an unsigned integer.
160
+        data=self.mux.read_i2c_block_data(self.channel, \
161
+             self.sensorAddr, VOLT_REG, 2)
162
+
163
+        if self.debugMode:
164
+            dataB1 = format(data[0], "08b")
165
+            dataB2 = format(data[1], "08b")
166
+            print("voltage register: %s %s" % (dataB1, dataB2))
167
+
168
+        # Convert data to volts.
169
+        volts = (data[0] << 8 | data[1]) * 0.00125 # LSB is 1.25 mV
170
+        return volts
171
+    ## end def
172
+
173
+    def getPower(self):
174
+        # Get the wattage data from the sensor.
175
+        # INA260 returns the data in two bytes formatted as follows
176
+        #        -------------------------------------------------
177
+        #    bit | 7   |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
178
+        #        -------------------------------------------------
179
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
180
+        #        -------------------------------------------------
181
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
182
+        #        -------------------------------------------------
183
+        # The wattage is returned in d15-d0 as an unsigned integer.
184
+        data=self.mux.read_i2c_block_data(self.channel, \
185
+                self.sensorAddr, PWR_REG, 2)
186
+
187
+        if self.debugMode:
188
+            dataB1 = format(data[0], "08b")
189
+            dataB2 = format(data[1], "08b")
190
+            print("power register: %s %s" % (dataB1, dataB2))
191
+
192
+        # Convert data to milliWatts. 
193
+        mW = (data[0] << 8 | data[1]) * 10.0  # LSB is 10.0 mW
194
+        return mW
195
+   ## end def
196
+## end class
197
+
198
+def test():
199
+    import time
200
+
201
+    # Initialize the smbus and INA260 sensor.
202
+    mux = i2cmux.i2cmux()
203
+    pwr1 = ina260(mux, sAddr=0x40, chan=0, debug=True)
204
+    # Print out sensor values.
205
+    while True:
206
+        print("%6.2f mA" % pwr1.getCurrent())
207
+        print("%6.2f V" % pwr1.getVoltage())
208
+        print("%6.2f mW\n" % pwr1.getPower())
209
+        time.sleep(2)
210
+## end def
211
+
212
+if __name__ == '__main__':
213
+    test()
214
+
0 215
new file mode 100755
... ...
@@ -0,0 +1,680 @@
1
+#!/usr/bin/python3 -u
2
+# The -u option above turns off block buffering of python output. This 
3
+# assures that each error message gets individually printed to the log file.
4
+#
5
+# Module: nodepowerAgent.py
6
+#
7
+# Description: This module acts as an agent between the mesh network and
8
+# node power and enviromental sensors.  The agent periodically polls the
9
+# sensors and processes the data returned from the sensors, including
10
+#     - conversion of data items
11
+#     - update a round robin (rrdtool) database with the sensor data
12
+#     - periodically generate graphic charts for display in html documents
13
+#     - write the processed node status to a JSON file for use by html
14
+#       documents
15
+#
16
+# Copyright 2022 Jeff Owrey
17
+#    This program is free software: you can redistribute it and/or modify
18
+#    it under the terms of the GNU General Public License as published by
19
+#    the Free Software Foundation, either version 3 of the License, or
20
+#    (at your option) any later version.
21
+#
22
+#    This program is distributed in the hope that it will be useful,
23
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
24
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25
+#    GNU General Public License for more details.
26
+#
27
+#    You should have received a copy of the GNU General Public License
28
+#    along with this program.  If not, see http://www.gnu.org/license.
29
+#
30
+# Revision History
31
+#   * v10 released 01 June 2021 by J L Owrey; first release
32
+#   * v11 released 02 July 2021 by J L Owrey; improved sensor fault
33
+#     handling; improved code readability
34
+#   * v12 released 06 July 2021 by J L Owrey; improved debug mode
35
+#     handling; debug mode state now passed to sensor object constructors
36
+#   * v20 released 16 March 2022 by J L Owrey; major revision to add
37
+#     fuctionality allowing i2c serial devices to use i2c serial bus
38
+#     multiplexer.  Also upgraded to python 3. 
39
+#
40
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
41
+
42
+# Import required python libraries.
43
+import os
44
+import sys
45
+import signal
46
+import subprocess
47
+import multiprocessing
48
+import time
49
+import json
50
+
51
+# Import sensor libraries.
52
+import ina260 # power sensor
53
+import tmp102 # temperature sensor
54
+
55
+# Import custom libraries
56
+import smsalert
57
+import i2cmux
58
+
59
+    ### ENVIRONMENT ###
60
+
61
+_USER = os.environ['USER']
62
+_HOSTNAME = os.uname()[1]
63
+
64
+    ### SMS RECIPIENTS ###
65
+
66
+_SMS_CALLSIGN = 'WA7ABU'
67
+_SMS_PASSCODE = '20919'
68
+_SMS_PHONE_NUMBER = '5039905829'
69
+
70
+    ### SENSOR BUS ADDRESSES ###
71
+
72
+# Set i2c multiplexer sensor channels.
73
+_PWR_MUX_CHANNEL = 0
74
+_BATTEMP_MUX_CHANNEL = 2
75
+_AMBTEMP_MUX_CHANNEL = 1
76
+
77
+# Set bus addresses of sensors.
78
+_PWR_SENSOR_ADDR = 0X40
79
+_BATTEMP_SENSOR_ADDR = 0x48
80
+_AMBTEMP_SENSOR_ADDR = 0x4B
81
+
82
+    ### FILE AND FOLDER LOCATIONS ###
83
+
84
+# folder to contain html
85
+_DOCROOT_PATH = "/home/%s/public_html/power/" % _USER
86
+# folder to contain charts and output data file
87
+_CHARTS_DIRECTORY = _DOCROOT_PATH + "dynamic/"
88
+# location of JSON output data file
89
+_OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/powerData.js"
90
+# database that stores node data
91
+_RRD_FILE = "/home/%s/database/powerData.rrd" % _USER
92
+
93
+    ### GLOBAL CONSTANTS ###
94
+
95
+# sensor data request interval in seconds
96
+_DEFAULT_SENSOR_POLLING_INTERVAL = 2
97
+# rrdtool database update interval in seconds
98
+_DATABASE_UPDATE_INTERVAL = 30
99
+# max number of failed attempts to get sensor data
100
+_MAX_FAILED_DATA_REQUESTS = 2
101
+
102
+# chart update interval in seconds
103
+_CHART_UPDATE_INTERVAL = 600
104
+# standard chart width in pixels
105
+_CHART_WIDTH = 600
106
+# standard chart height in pixels
107
+_CHART_HEIGHT = 150
108
+# chart average line color
109
+_AVERAGE_LINE_COLOR = '#006600'
110
+# low voltage alert threshold
111
+_DEFAULT_CRITICAL_LOW_VOLTAGE = 13.0
112
+
113
+   ### GLOBAL VARIABLES ###
114
+
115
+# Sensor instance objects.
116
+power1 = None
117
+battemp = None
118
+ambtemp = None
119
+
120
+# turns on or off extensive debugging messages
121
+debugMode = False
122
+verboseMode = False
123
+
124
+# frequency of data requests to sensors
125
+dataRequestInterval = _DEFAULT_SENSOR_POLLING_INTERVAL
126
+# how often charts get updated
127
+chartUpdateInterval = _CHART_UPDATE_INTERVAL
128
+# critical low voltage threshold
129
+criticalLowVoltage = _DEFAULT_CRITICAL_LOW_VOLTAGE
130
+# number of failed attempts to get sensor data
131
+failedUpdateCount = 0
132
+# sensor status
133
+deviceOnline = False
134
+# sms message sent status
135
+bSMSmsgSent = False
136
+
137
+  ###  PRIVATE METHODS  ###
138
+
139
+def getTimeStamp():
140
+    """
141
+    Get the local time and format as a text string.
142
+    Parameters: none
143
+    Returns: string containing the time stamp
144
+    """
145
+    return time.strftime( "%m/%d/%Y %T", time.localtime() )
146
+## end def
147
+
148
+def getEpochSeconds(sTime):
149
+    """
150
+    Convert the time stamp to seconds since 1/1/1970 00:00:00.
151
+    Parameters: 
152
+        sTime - the time stamp to be converted must be formatted
153
+                   as %m/%d/%Y %H:%M:%S
154
+    Returns: epoch seconds
155
+    """
156
+    try:
157
+        t_sTime = time.strptime(sTime, '%m/%d/%Y %H:%M:%S')
158
+    except Exception as exError:
159
+        print('%s getEpochSeconds: %s' % (getTimeStamp(), exError))
160
+        return None
161
+    tSeconds = int(time.mktime(t_sTime))
162
+    return tSeconds
163
+## end def
164
+
165
+def setStatusToOffline():
166
+    """Set the detected status of the device to
167
+       "offline" and inform downstream clients by removing input
168
+       and output data files.
169
+       Parameters: none
170
+       Returns: nothing
171
+    """
172
+    global deviceOnline
173
+
174
+    # Inform downstream clients by removing output data file.
175
+    if os.path.exists(_OUTPUT_DATA_FILE):
176
+       os.remove(_OUTPUT_DATA_FILE)
177
+    # If the sensor or  device was previously online, then send
178
+    # a message that we are now offline.
179
+    if deviceOnline:
180
+        print('%s device offline' % getTimeStamp())
181
+    deviceOnline = False
182
+##end def
183
+
184
+def terminateAgentProcess(signal, frame):
185
+    """Send a message to log when the agent process gets killed
186
+       by the operating system.  Inform downstream clients
187
+       by removing input and output data files.
188
+       Parameters:
189
+           signal, frame - dummy parameters
190
+       Returns: nothing
191
+    """
192
+    print('%s terminating agent process' % getTimeStamp())
193
+    setStatusToOffline()
194
+    sys.exit(0)
195
+##end def
196
+
197
+def sendSmsMessage(message, phone):
198
+    """
199
+    Sends a SMS text message alert
200
+    Parameters: message - the message to send
201
+                phone - the phone number to which to send the message
202
+    Returns: Nothing
203
+    """
204
+    sms = smsalert.smsalert(_SMS_CALLSIGN, _SMS_PASSCODE, debug=debugMode)
205
+    # Send the text alert to recipient phone numbers.
206
+    sms.sendSMS(phone, message)
207
+    del sms
208
+## end def
209
+
210
+  ###  PUBLIC METHODS  ###
211
+
212
+def getSensorData(dData):
213
+    """
214
+    Poll sensors for data. Store the data in a dictionary object for
215
+    use by other subroutines.  The dictionary object passed in should
216
+    an empty dictionary, i.e., dData = { }.
217
+    Parameters: dData - a dictionary object to contain the sensor data
218
+                dSensors - a dictionary containing sensor objects
219
+    Returns: True if successful, False otherwise
220
+    """
221
+    dData["time"] = getTimeStamp()
222
+ 
223
+    try:
224
+        dData["current"] = power1.getCurrent()
225
+        dData["voltage"] = power1.getVoltage()
226
+        dData["power"] =   power1.getPower()
227
+        dData["battemp"] = battemp.getTempF()
228
+        dData["ambtemp"] = ambtemp.getTempF()
229
+    except Exception as exError:
230
+        print("%s sensor error: %s" % (getTimeStamp(), exError))
231
+        return False
232
+
233
+    dData['chartUpdateInterval'] = chartUpdateInterval
234
+    dData['criticalLowVoltage' ] = criticalLowVoltage
235
+
236
+    return True
237
+## end def
238
+
239
+def convertData(dData):
240
+    """
241
+    Converts data items and verifies threshold crossings.  Sends SMS
242
+    text message if a threshold has been crossed.
243
+    Parameters: dData - a dictionary object that contains the sensor data
244
+    Returns: True if successful, False otherwise
245
+    """
246
+    global bSMSmsgSent
247
+ 
248
+    phone = _SMS_PHONE_NUMBER
249
+
250
+    if not bSMSmsgSent and dData["voltage"] <= criticalLowVoltage:
251
+        # Format a text alert message.
252
+        message = "%s %s low voltage alert: %.2f volts" % \
253
+                  (getTimeStamp(), _HOSTNAME, float(dData["voltage"]))
254
+        print(message)
255
+        bSMSmsgSent = True
256
+        p = multiprocessing.Process(target=sendSmsMessage, \
257
+            args=(message, phone,))
258
+        p.start()
259
+    elif bSMSmsgSent and dData["voltage"] > criticalLowVoltage:
260
+        # Format a text alert message.
261
+        message = "%s %s voltage normal: %.2f volts" % \
262
+                  (getTimeStamp(), _HOSTNAME, float(dData["voltage"]))
263
+        print(message)
264
+        bSMSmsgSent = False
265
+        p = multiprocessing.Process(target=sendSmsMessage, \
266
+            args=(message,phone,))
267
+        p.start()
268
+    return True
269
+## end def
270
+
271
+def writeOutputFile(dData):
272
+    """
273
+    Write sensor data items to the output data file, formatted as 
274
+    a Javascript file.  This file may then be requested and used by
275
+    by downstream clients, for instance, an HTML document.
276
+    Parameters:
277
+        dData - a dictionary containing the data to be written
278
+                   to the output data file
279
+        Returns: True if successful, False otherwise
280
+    """
281
+    # Write a JSON formatted file for use by html clients.  The following
282
+    # data items are sent to the client file.
283
+    #    * The last database update date and time
284
+    #    * The data request interval
285
+    #    * The sensor values
286
+
287
+    # Create a JSON formatted string from the sensor data.
288
+    jsData = json.loads("{}")
289
+    try:
290
+        for key in dData:
291
+            jsData.update({key:dData[key]})
292
+        sData = "[%s]" % json.dumps(jsData)
293
+    except Exception as exError:
294
+        print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
295
+        return False
296
+
297
+    if debugMode:
298
+        print(sData)
299
+
300
+    # Write the JSON formatted data to the output data file.
301
+
302
+    try:
303
+        fc = open(_OUTPUT_DATA_FILE, "w")
304
+        fc.write(sData)
305
+        fc.close()
306
+    except Exception as exError:
307
+        print("%s write output file failed: %s" % \
308
+              (getTimeStamp(), exError))
309
+        return False
310
+
311
+    return True
312
+## end def
313
+
314
+def setStatus(updateSuccess):
315
+    """Detect if device is offline or not available on
316
+       the network. After a set number of attempts to get data
317
+       from the device set a flag that the device is offline.
318
+       Parameters:
319
+           updateSuccess - a boolean that is True if data request
320
+                           successful, False otherwise
321
+       Returns: nothing
322
+    """
323
+    global failedUpdateCount, deviceOnline
324
+
325
+    if updateSuccess:
326
+        failedUpdateCount = 0
327
+        # Set status and send a message to the log if the device
328
+        # previously offline and is now online.
329
+        if not deviceOnline:
330
+            print('%s device online' % getTimeStamp())
331
+            deviceOnline = True
332
+        return
333
+    else:
334
+        # The last attempt failed, so update the failed attempts
335
+        # count.
336
+        failedUpdateCount += 1
337
+
338
+    if failedUpdateCount == _MAX_FAILED_DATA_REQUESTS:
339
+        # Max number of failed data requests, so set
340
+        # device status to offline.
341
+        setStatusToOffline()
342
+##end def
343
+
344
+    ### DATABASE FUNCTIONS ###
345
+
346
+def updateDatabase(dData):
347
+    """
348
+    Update the rrdtool database by executing an rrdtool system command.
349
+    Format the command using the data extracted from the sensors.
350
+    Parameters: dData - dictionary object containing data items to be
351
+                        written to the rr database file
352
+    Returns: True if successful, False otherwise
353
+    """
354
+ 
355
+    epochTime = getEpochSeconds(dData['time'])
356
+
357
+    # Format the rrdtool update command.
358
+    strFmt = "rrdtool update %s %s:%s:%s:%s:%s:%s"
359
+    strCmd = strFmt % (_RRD_FILE, epochTime, dData['current'], \
360
+             dData['voltage'], dData['power'], dData['battemp'], \
361
+             dData['ambtemp'])
362
+
363
+    if debugMode:
364
+        print("%s" % strCmd) # DEBUG
365
+
366
+    # Run the command as a subprocess.
367
+    try:
368
+        subprocess.check_output(strCmd, shell=True, \
369
+            stderr=subprocess.STDOUT)
370
+    except subprocess.CalledProcessError as exError:
371
+        print("%s: rrdtool update: %s" % \
372
+            (getTimeStamp(), exError.output))
373
+        return False
374
+
375
+    if verboseMode and not debugMode:
376
+        print("database update successful")
377
+
378
+    return True
379
+## end def
380
+
381
+def createGraph(fileName, dataItem, gLabel, gTitle, gStart,
382
+                lower=0, upper=0, trendLine=0, scaleFactor=1,
383
+                autoScale=True, alertLine=""):
384
+    """
385
+    Uses rrdtool to create a graph of specified sensor data item.
386
+    Parameters:
387
+        fileName - name of file containing the graph
388
+        dataItem - data item to be graphed
389
+        gLabel - string containing a graph label for the data item
390
+        gTitle - string containing a title for the graph
391
+        gStart - beginning time of the graphed data
392
+        lower - lower bound for graph ordinate #NOT USED
393
+        upper - upper bound for graph ordinate #NOT USED
394
+        trendLine 
395
+            0, show only graph data
396
+            1, show only a trend line
397
+            2, show a trend line and the graph data
398
+        scaleFactor - amount to pre-scale the data before charting
399
+            the data [default=1]
400
+        autoScale - if True, then use vertical axis auto scaling
401
+            (lower and upper parameters must be zero)
402
+        alertLine - value for which to print a critical
403
+            low voltage alert line on the chart. If not provided
404
+            alert line will not be printed.
405
+    Returns: True if successful, False otherwise
406
+    """
407
+    gPath = _CHARTS_DIRECTORY + fileName + ".png"
408
+    trendWindow = { 'end-1day': 7200,
409
+                    'end-4weeks': 172800,
410
+                    'end-12months': 604800 }
411
+ 
412
+    # Format the rrdtool graph command.
413
+
414
+    # Set chart start time, height, and width.
415
+    strCmd = "rrdtool graph %s -a PNG -s %s -e now -w %s -h %s " \
416
+             % (gPath, gStart, _CHART_WIDTH, _CHART_HEIGHT)
417
+   
418
+    # Set the range and scaling of the chart y-axis.
419
+    if lower < upper:
420
+        strCmd  +=  "-l %s -u %s -r " % (lower, upper)
421
+    elif autoScale:
422
+        strCmd += "-A "
423
+    strCmd += "-Y "
424
+
425
+    # Set the chart ordinate label and chart title. 
426
+    strCmd += "-v %s -t %s " % (gLabel, gTitle)
427
+ 
428
+    # Show the data, or a moving average trend line over
429
+    # the data, or both.
430
+    strCmd += "DEF:rSeries=%s:%s:LAST " % (_RRD_FILE, dataItem)
431
+    strCmd += "CDEF:dSeries=rSeries,%s,/ " % (scaleFactor)
432
+
433
+    if trendLine == 0:
434
+        strCmd += "LINE1:dSeries#0400ff "
435
+    elif trendLine == 1:
436
+        strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
437
+                  % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
438
+    elif trendLine == 2:
439
+        strCmd += "LINE1:dSeries#0400ff "
440
+        strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
441
+                  % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
442
+
443
+    if alertLine != "":
444
+        strCmd += "HRULE:%s#FF0000:Critical\ Low\ Voltage " % (alertLine)
445
+     
446
+    if debugMode:
447
+        print("%s\n" % strCmd) # DEBUG
448
+    
449
+    # Run the formatted rrdtool command as a subprocess.
450
+    try:
451
+        result = subprocess.check_output(strCmd, \
452
+                     stderr=subprocess.STDOUT,   \
453
+                     shell=True)
454
+    except subprocess.CalledProcessError as exError:
455
+        print("%s rrdtool graph: %s" % \
456
+              (getTimeStamp(), exError.output))
457
+        return False
458
+
459
+    if verboseMode and not debugMode:
460
+        print("rrdtool graph: %s" % result.decode('utf-8'))
461
+    return True
462
+
463
+## end def
464
+
465
+def generateGraphs():
466
+    """
467
+    Generate graphs for display in html documents.
468
+    Parameters: none
469
+    Returns: nothing
470
+    """
471
+
472
+    # 24 hour stock charts
473
+
474
+    createGraph('24hr_current', 'CUR', 'Amps',
475
+                'Current\ -\ Last\ 24\ Hours', 'end-1day', \
476
+                0, 0, 2, 1000)
477
+    createGraph('24hr_voltage', 'VOLT', 'Volts',
478
+                'Voltage\ -\ Last\ 24\ Hours', 'end-1day', \
479
+                9, 15, 0, 1, True, _DEFAULT_CRITICAL_LOW_VOLTAGE)
480
+    createGraph('24hr_power', 'PWR', 'Watts', 
481
+                'Power\ -\ Last\ 24\ Hours', 'end-1day', \
482
+                0, 0, 2, 1000)
483
+    createGraph('24hr_battemp', 'BTMP', 'deg\ F', 
484
+                'Outside\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
485
+                0, 0, 0)
486
+    createGraph('24hr_ambtemp', 'ATMP', 'deg\ F', 
487
+                'Room\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
488
+                0, 0, 0)
489
+
490
+    # 4 week stock charts
491
+
492
+    createGraph('4wk_current', 'CUR', 'Amps',
493
+                'Current\ -\ Last\ 4\ Weeks', 'end-4weeks', \
494
+                0, 0, 2, 1000)
495
+    createGraph('4wk_voltage', 'VOLT', 'Volts',
496
+                'Voltage\ -\ Last\ 4\ Weeks', 'end-4weeks', \
497
+                9, 15, 0, 1, True, _DEFAULT_CRITICAL_LOW_VOLTAGE)
498
+    createGraph('4wk_power', 'PWR', 'Watts', 
499
+                'Power\ -\ Last\ 4\ Weeks', 'end-4weeks', \
500
+                0, 0, 2, 1000)
501
+    createGraph('4wk_battemp', 'BTMP', 'deg\ F', 
502
+                'Outside\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
503
+                0, 0, 2)
504
+    createGraph('4wk_ambtemp', 'ATMP', 'deg\ F', 
505
+                'Room\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
506
+                0, 0, 2)
507
+
508
+    # 12 month stock charts
509
+
510
+    createGraph('12m_current', 'CUR', 'Amps',
511
+                'Current\ -\ Past\ Year', 'end-12months', \
512
+                0, 0, 2, 1000)
513
+    createGraph('12m_voltage', 'VOLT', 'Volts',
514
+                'Voltage\ -\ Past\ Year', 'end-12months', \
515
+                9, 15, 0, 1, True, _DEFAULT_CRITICAL_LOW_VOLTAGE)
516
+    createGraph('12m_power', 'PWR', 'Watts', 
517
+                'Power\ -\ Past\ Year', 'end-12months', \
518
+                0, 0, 2, 1000)
519
+    createGraph('12m_battemp', 'BTMP', 'deg\ F', 
520
+                'Outside\ Temperature\ -\ Past\ Year', 'end-12months', \
521
+                0, 0, 2)
522
+    createGraph('12m_ambtemp', 'ATMP', 'deg\ F', 
523
+                'Room\ Temperature\ -\ Past\ Year', 'end-12months', \
524
+                0, 0, 2)
525
+## end def
526
+
527
+def getCLarguments():
528
+    """
529
+    Get command line arguments.  There are three possible arguments
530
+        -d turns on debug mode
531
+        -v turns on verbose mode
532
+        -p sets the sensor query period
533
+        -c sets the chart update period
534
+        -L sets the critical low voltage threshold
535
+    Returns: nothing
536
+    """
537
+    global debugMode, verboseMode, dataRequestInterval, chartUpdateInterval
538
+    global criticalLowVoltage
539
+
540
+    index = 1
541
+    while index < len(sys.argv):
542
+        if sys.argv[index] == '-v':
543
+            verboseMode = True
544
+        elif sys.argv[index] == '-d':
545
+            debugMode = True
546
+            verboseMode = True
547
+        elif sys.argv[index] == '-p':
548
+            try:
549
+                dataRequestInterval = abs(int(sys.argv[index + 1]))
550
+            except:
551
+                print("invalid sensor query period")
552
+                exit(-1)
553
+            index += 1
554
+        elif sys.argv[index] == '-c':
555
+            try:
556
+                chartUpdateInterval = abs(int(sys.argv[index + 1]))
557
+            except:
558
+                print("invalid chart update period")
559
+                exit(-1)
560
+            index += 1
561
+        elif sys.argv[index] == '-L':
562
+            try:
563
+                criticalLowVoltage = abs(float(sys.argv[index + 1]))
564
+            except:
565
+                print("invalid critical low voltage")
566
+                exit(-1)
567
+            index += 1
568
+        else:
569
+            cmd_name = sys.argv[0].split('/')
570
+            print("Usage: %s [-d | v] [-p seconds] [-c seconds]" \
571
+                  % cmd_name[-1])
572
+            exit(-1)
573
+        index += 1
574
+##end def
575
+
576
+def main_setup():
577
+    """
578
+    Handles timing of events and acts as executive routine managing
579
+    all other functions.
580
+    Parameters: none
581
+    Returns: nothing
582
+    """
583
+    global power1, battemp, ambtemp, sms
584
+
585
+    signal.signal(signal.SIGTERM, terminateAgentProcess)
586
+    signal.signal(signal.SIGINT, terminateAgentProcess)
587
+
588
+    # Log agent process startup time.
589
+    print('========================================================')
590
+    #print('===', end='')
591
+    print('%s starting up node power agent process' % \
592
+                  (getTimeStamp()))
593
+
594
+    ## Get command line arguments.
595
+    getCLarguments()
596
+
597
+    ## Exit with error if rrdtool database does not exist.
598
+    if not os.path.exists(_RRD_FILE):
599
+        print('rrdtool database does not exist\n' \
600
+              'use createPowerRrd script to ' \
601
+              'create rrdtool database\n')
602
+        exit(1)
603
+    # Create an i2c serial bus multiplexer object.
604
+    mux = i2cmux.i2cmux()
605
+
606
+    # Create sensor objects.  This also initializes each sensor.
607
+    power1 = ina260.ina260(mux, _PWR_SENSOR_ADDR, _PWR_MUX_CHANNEL,
608
+                            debug=debugMode)
609
+    battemp = tmp102.tmp102(mux, _BATTEMP_SENSOR_ADDR, _BATTEMP_MUX_CHANNEL,
610
+                            debug=debugMode)
611
+    ambtemp = tmp102.tmp102(mux, _AMBTEMP_SENSOR_ADDR, _AMBTEMP_MUX_CHANNEL,
612
+                            debug=debugMode)
613
+## end def
614
+
615
+def main_loop():
616
+    # last time output JSON file updated
617
+    lastDataRequestTime = -1
618
+    # last time charts generated
619
+    lastChartUpdateTime = - 1
620
+    # last time the rrdtool database updated
621
+    lastDatabaseUpdateTime = -1
622
+
623
+    ### MAIN LOOP ###
624
+
625
+    while True:
626
+
627
+        currentTime = time.time() # get current time in seconds
628
+
629
+        # Every data request interval read the sensors and process the
630
+        # data from the sensors.
631
+        if currentTime - lastDataRequestTime > dataRequestInterval:
632
+            lastDataRequestTime = currentTime
633
+            dData = {}
634
+
635
+            # Get the data from the sensors.
636
+            result = getSensorData(dData)
637
+ 
638
+            # If getting the data successful, then convert the data.
639
+            if result:
640
+                result = convertData(dData)
641
+
642
+            # If convert data successful, write data to data files.
643
+            if result:
644
+                result = writeOutputFile(dData)
645
+
646
+            # At the rrdtool database update interval, update the database.
647
+            if result and (currentTime - lastDatabaseUpdateTime > \
648
+                           _DATABASE_UPDATE_INTERVAL):   
649
+                lastDatabaseUpdateTime = currentTime
650
+                ## Update the round robin database with the parsed data.
651
+                result = updateDatabase(dData)
652
+
653
+            setStatus(result)
654
+
655
+        # At the chart generation interval, generate charts.
656
+        if currentTime - lastChartUpdateTime > chartUpdateInterval:
657
+            lastChartUpdateTime = currentTime
658
+            p = multiprocessing.Process(target=generateGraphs, args=())
659
+            p.start()
660
+            
661
+        # Relinquish processing back to the operating system until
662
+        # the next update interval.
663
+
664
+        elapsedTime = time.time() - currentTime
665
+        if verboseMode:
666
+            if result:
667
+                print("update successful: %6f sec\n"
668
+                      % elapsedTime)
669
+            else:
670
+                print("update failed: %6f sec\n"
671
+                      % elapsedTime)
672
+        remainingTime = dataRequestInterval - elapsedTime
673
+        if remainingTime > 0.0:
674
+            time.sleep(remainingTime)
675
+    ## end while
676
+## end def
677
+
678
+if __name__ == '__main__':
679
+    main_setup()
680
+    main_loop()
0 681
similarity index 100%
1 682
rename from nodepower/bin/npwstart
2 683
rename to nodepower/bin/i2cmux/npwstart
3 684
similarity index 100%
4 685
rename from nodepower/bin/npwstop
5 686
rename to nodepower/bin/i2cmux/npwstop
6 687
new file mode 100755
... ...
@@ -0,0 +1,124 @@
1
+#!/usr/bin/python3
2
+#!/usr/bin/python3
3
+#
4
+# Module: smsalert.py
5
+#
6
+# Description: This module provides a utility for sending SMS text
7
+# messages to an SMS Gateway server.  The gateway server sends the
8
+# text message to the supplied phone number.  This class acts as a
9
+# library module that can be imported into and called from other
10
+# Python programs.
11
+#
12
+# Copyright 2022 Jeff Owrey
13
+#    This program is free software: you can redistribute it and/or modify
14
+#    it under the terms of the GNU General Public License as published by
15
+#    the Free Software Foundation, either version 3 of the License, or
16
+#    (at your option) any later version.
17
+#
18
+#    This program is distributed in the hope that it will be useful,
19
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
20
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
+#    GNU General Public License for more details.
22
+#
23
+#    You should have received a copy of the GNU General Public License
24
+#    along with this program.  If not, see http://www.gnu.org/license.
25
+#
26
+# Revision History
27
+#   * v10 released 16 March 2022 by J L Owrey; first release
28
+#
29
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
30
+
31
+import telnetlib
32
+
33
+_DEFAULT_HOST = 'ai7nc-aprs-is-vm.local.mesh'
34
+_DEFAULT_PORT = '14580'
35
+_DEFAULT_SERVER = 'AI7NC-30'
36
+
37
+class smsalert:
38
+
39
+    def __init__(self, callsign, passcode, host=_DEFAULT_HOST, \
40
+      port=_DEFAULT_PORT, server=_DEFAULT_SERVER, debug=False):
41
+        """
42
+        Initialize an instance of this class.
43
+        Parameters:
44
+          callsign - amateur radio callsign of user (must be verified)
45
+          passcode - passcode for verified callsign
46
+          host - domain name or IP address of APRS-IS server
47
+          port - port on which the APRS-IS server receives messages
48
+          server - APRS service name
49
+          debug - set equal to True for debug output
50
+        Returns: nothing
51
+        """
52
+        # Initialize class instance variables.
53
+        self.callsign = callsign
54
+        self.passcode = passcode
55
+        self.host = host
56
+        self.port = port
57
+        self.server = server
58
+        self.debug = debug
59
+    ## end def
60
+
61
+    def sendSMS(self, phone_number, text_message):
62
+        """
63
+        Sends an SMS text message to the provided phone number.
64
+        Parameters:
65
+          phone_number - phone number to which to send the text message
66
+          text_message - text message to be sent to the provided
67
+          phone number
68
+        Returns: True if successful, False otherwise
69
+        """
70
+        initial_prompt = '# aprsc 2.1.8-gf8824e8'
71
+        login_string = 'user ' + self.callsign + \
72
+                       ' pass ' + self.passcode + '\n'
73
+        login_result = self.server
74
+
75
+        # For compatibility with python 2 telnet library, convert
76
+        # utf-8 strings to bytes.
77
+        initial_prompt = initial_prompt.encode()
78
+        login_string = login_string.encode()
79
+        login_result = login_result.encode()
80
+
81
+        try:
82
+            # Establish network connection to APRS-IS server. Look for
83
+            tn = telnetlib.Telnet(self.host, self.port)
84
+            tn.read_until(initial_prompt)
85
+
86
+            # Login and verify passcode accepted.
87
+            tn.write(login_string)
88
+            response = tn.read_until(login_result)
89
+            response = response.decode() # convert response to utf-8 string
90
+            if self.debug:
91
+                print('sms response: ' + response[0:])
92
+            if not response.find('verified'):
93
+                print('sms error: unverified user')
94
+                del tn
95
+                return False
96
+
97
+            # Format and send SMS message to SMS gateway.
98
+            cmd = '%s>%s::SMSGTE:@%s %s\n' % \
99
+              (self.callsign, self.server, phone_number, text_message)
100
+            if self.debug:
101
+                print('sms cmd: ' + cmd)
102
+            tn.write(cmd.encode())
103
+            del tn
104
+            return True
105
+        except Exception as exError:
106
+            print("sms error: %s" % (exError))
107
+            return False
108
+    ## end def
109
+## end class
110
+
111
+def test_smsalert():
112
+    import time
113
+
114
+    # Initialize a telnet instance.  Default host, port, and server
115
+    # automatically defined if not included in function call.
116
+    sm = smsalert('KA7JLO', '17318', debug=True)
117
+
118
+    # Send a text message to a phone number.
119
+    message = 'Python script test message sent via AREDN: msg %d' % time.time()
120
+    sm.sendSMS('5416021314', message)
121
+## end def
122
+
123
+if __name__ == '__main__':
124
+    test_smsalert()
0 125
new file mode 100755
... ...
@@ -0,0 +1,171 @@
1
+#!/usr/bin/python3
2
+#
3
+# Module: tmp102.py
4
+#
5
+# Description: This module acts as an interface between the TMP102 sensor
6
+# and downstream applications that use the data.  Class methods get
7
+# temperature data from the TMP102 sensor. It acts as a library module that
8
+# can be imported into and called from other Python programs.
9
+#
10
+# Copyright 2022 Jeff Owrey
11
+#    This program is free software: you can redistribute it and/or modify
12
+#    it under the terms of the GNU General Public License as published by
13
+#    the Free Software Foundation, either version 3 of the License, or
14
+#    (at your option) any later version.
15
+#
16
+#    This program is distributed in the hope that it will be useful,
17
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19
+#    GNU General Public License for more details.
20
+#
21
+#    You should have received a copy of the GNU General Public License
22
+#    along with this program.  If not, see http://www.gnu.org/license.
23
+#
24
+# Revision History
25
+#   * v10 released 01 June 2021 by J L Owrey; first release
26
+#   * v20 released 16 March 2022 by J L Owrey; major revision to add
27
+#     fuctionality allowing i2c serial devices to use i2c serial bus
28
+#     multiplexer.  Also upgraded to python 3. 
29
+#
30
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
31
+
32
+# Import the I2C interface library
33
+import i2cmux
34
+
35
+# Define constants
36
+DEGSYM = u'\xB0'
37
+
38
+# Define TMP102 Device Registers
39
+CONFIG_REG = 0x1
40
+TEMP_REG = 0x0
41
+
42
+# Define default sm bus address.
43
+DEFAULT_MUX_CHANNEL = 0
44
+DEFAULT_BUS_ADDRESS = 0x48
45
+DEFAULT_BUS_NUMBER = 1
46
+
47
+# Define the default sensor configuration.  See the TMP102 data sheet
48
+# for meaning of each bit.  The following bytes are written to the
49
+# configuration register
50
+#     byte 1: 01100000
51
+#     byte 2: 10100000
52
+DEFAULT_CONFIG = 0x60A0
53
+
54
+class tmp102:
55
+
56
+    # Initialize the TMP102 sensor at the supplied address (default
57
+    # address is 0x48), and supplied bus (default is 1).  Creates
58
+    # a new SMBus object for each instance of this class.  Writes
59
+    # configuration data (two bytes) to the TMP102 configuration
60
+    # register.
61
+    def __init__(self, objMux, sAddr=DEFAULT_BUS_ADDRESS,
62
+                 chan=DEFAULT_MUX_CHANNEL,
63
+                 config=DEFAULT_CONFIG,
64
+                 debug=False): 
65
+        # Instantiate a smbus object
66
+        self.mux = objMux
67
+        self.sensorAddr = sAddr
68
+        self.channel = chan 
69
+        self.debugMode = debug
70
+
71
+        # Initialize TMP102 sensor.  
72
+        initData = [(config >> 8), (config & 0x00FF)]
73
+        self.mux.write_i2c_block_data(self.channel, self.sensorAddr, \
74
+                CONFIG_REG, initData)
75
+
76
+        if self.debugMode:
77
+            # Read the TMP102 configuration register.
78
+            data = self.getInfo()
79
+            print(self)
80
+            print("temp config register: %s %s\n" % data)
81
+    ## end def
82
+
83
+    # Reads the configuration register (two bytes).
84
+    def getInfo(self):
85
+        # Read configuration data
86
+        config = self.mux.read_i2c_block_data(self.channel, \
87
+                self.sensorAddr, CONFIG_REG, 2)
88
+        configB1 = format(config[0], "08b")
89
+        configB2 = format(config[1], "08b")
90
+        return (configB1, configB2)
91
+    ## end def
92
+
93
+    def getTempReg(self):
94
+        # Read temperature register and return raw binary data for test
95
+        # and debug.
96
+        data = self.mux.read_i2c_block_data(self.channel, \
97
+                self.sensorAddr, TEMP_REG, 2)
98
+        dataB1 = format(data[0], "08b")
99
+        dataB2 = format(data[1], "08b")
100
+        return (dataB1, dataB2)
101
+    ## end def
102
+
103
+    # Gets the temperature in binary format and converts to degrees
104
+    # Celsius.
105
+    def getTempC(self):
106
+        # Get temperature data from the sensor.
107
+        # TMP102 returns the data in two bytes formatted as follows
108
+        #        -------------------------------------------------
109
+        #    bit | b7  | b6  | b5  | b4  | b3  | b2  | b1  | b0  |
110
+        #        -------------------------------------------------
111
+        # byte 1 | d11 | d10 | d9  | d8  | d7  | d6  | d5  | d4  |
112
+        #        -------------------------------------------------
113
+        # byte 2 | d3  | d2  | d1  | d0  | 0   |  0  |  0  |  0  |
114
+        #        -------------------------------------------------
115
+        # The temperature is returned in d11-d0, a two's complement,
116
+        # 12 bit number.  This means that d11 is the sign bit.
117
+        data=self.mux.read_i2c_block_data(self.channel, \
118
+                self.sensorAddr, TEMP_REG, 2)
119
+
120
+        if self.debugMode:
121
+            dataB1 = format(data[0], "08b")
122
+            dataB2 = format(data[1], "08b")
123
+            print("Temperature Reg: %s %s" % (dataB1, dataB2))
124
+
125
+        # Format into a 12 bit word.
126
+        bData = ( data[0] << 8 | data[1] ) >> 4
127
+        # Convert from two's complement to integer.
128
+        # If d11 is 1, the the number is a negative two's complement
129
+        # number.  The absolute value is 2^12 - 1 minus the value
130
+        # of d11-d0 taken as a positive number.
131
+        if bData > 0x7FF:  # all greater values are negative numbers
132
+            bData = -(0xFFF - bData)  # 0xFFF is 2^12 - 1
133
+        # convert integer data to Celsius
134
+        tempC = bData * 0.0625 # LSB is 0.0625 deg Celsius
135
+        return tempC
136
+    ## end def
137
+
138
+    def getTempF(self):
139
+        # Convert Celsius to Fahrenheit using standard formula.
140
+        tempF = (9./5.) * self.getTempC() + 32.
141
+        return tempF
142
+    ## end def
143
+## end class
144
+
145
+def testclass():
146
+    import time
147
+
148
+    # Initialize the smbus and TMP102 sensor.
149
+    mux = i2cmux.i2cmux()
150
+    ts1 = tmp102(mux, sAddr=0x4B, chan=1, debug=True)
151
+
152
+    # Print out sensor values.
153
+    bAl = False
154
+    while True:
155
+        tempC = ts1.getTempC()
156
+        tempF = ts1.getTempF()
157
+        if bAl:
158
+            bAl = False
159
+            print("\033[42;30m%6.2f%sC  %6.2f%sF                 \033[m" % \
160
+                  (tempC, DEGSYM, tempF, DEGSYM))
161
+        else:
162
+            bAl = True
163
+            print("%6.2f%sC  %6.2f%sF" % \
164
+                  (tempC, DEGSYM, tempF, DEGSYM))
165
+        time.sleep(2)
166
+    ## end while
167
+## end def
168
+
169
+if __name__ == '__main__':
170
+    testclass()
171
+
0 172
similarity index 100%
1 173
rename from nodepower/bin/createPowerRrd.py
2 174
rename to nodepower/bin/python2/createPowerRrd.py
3 175
similarity index 100%
4 176
rename from nodepower/bin/ina260.py
5 177
rename to nodepower/bin/python2/ina260.py
6 178
similarity index 100%
7 179
rename from nodepower/bin/npwAgent.py
8 180
rename to nodepower/bin/python2/npwAgent.py
9 181
new file mode 100755
... ...
@@ -0,0 +1,25 @@
1
+#!/bin/bash
2
+# Starts up the node power agent as a background process
3
+# and redirects output to a log file.
4
+
5
+APP_PATH="/home/$USER/bin"
6
+LOG_PATH="/home/$USER/log"
7
+
8
+AGENT_NAME="[n]pwAgent.py"
9
+
10
+PROCESS_ID="$(ps x | awk -v a=$AGENT_NAME '$7 ~ a {print $1}')"
11
+
12
+if [ -n "$PROCESS_ID" ]; then
13
+  if [ "$1" != "-q" ]; then
14
+    printf "node power agent running [%s]\n" $PROCESS_ID
15
+  fi
16
+else
17
+  printf "starting up node agent\n"
18
+  cd $APP_PATH
19
+  if [ "$1" != "" ]; then
20
+    ./$AGENT_NAME $1
21
+  else
22
+    ./$AGENT_NAME >> $LOG_PATH/npwAgent.log 2>&1 &
23
+  fi
24
+fi
25
+
0 26
new file mode 100755
... ...
@@ -0,0 +1,13 @@
1
+#!/bin/bash
2
+# Stop the node power agent process and clean up environment.
3
+
4
+AGENT_NAME="[n]pwAgent.py"
5
+
6
+PROCESS_ID="$(ps x | awk -v a=$AGENT_NAME '$7 ~ a {print $1}')"
7
+
8
+if [ -n "$PROCESS_ID" ]; then
9
+  printf "killing node power agent [%s]\n" $PROCESS_ID
10
+  kill $PROCESS_ID
11
+else
12
+  echo node power agent not running
13
+fi
0 14
similarity index 100%
1 15
rename from nodepower/bin/smsalert.py
2 16
rename to nodepower/bin/python2/smsalert.py
3 17
similarity index 100%
4 18
rename from nodepower/bin/tmp102.py
5 19
rename to nodepower/bin/python2/tmp102.py
6 20
new file mode 100644
... ...
@@ -0,0 +1,93 @@
1
+#!/usr/bin/python3 -u
2
+## The -u option above turns off block buffering of python output. This assures
3
+## that each error message gets individually printed to the log file.
4
+#
5
+# Module: createPowerRrd.py
6
+#
7
+# Description: Creates a rrdtool database for use by the power agent to
8
+# store the data from the power and temperature sensors.  The agent uses
9
+# the data in the database to generate graphic charts for display in the
10
+# weather station web page.
11
+#
12
+# Copyright 2021 Jeff Owrey
13
+#    This program is free software: you can redistribute it and/or modify
14
+#    it under the terms of the GNU General Public License as published by
15
+#    the Free Software Foundation, either version 3 of the License, or
16
+#    (at your option) any later version.
17
+#
18
+#    This program is distributed in the hope that it will be useful,
19
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
20
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
+#    GNU General Public License for more details.
22
+#
23
+#    You should have received a copy of the GNU General Public License
24
+#    along with this program.  If not, see http://www.gnu.org/license.
25
+#
26
+# Revision History
27
+#   * v10 released 01 Jun 2021 by J L Owrey
28
+#
29
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
30
+
31
+import os
32
+import time
33
+import subprocess
34
+
35
+    ### DEFINE DATABASE FILE LOCATION ###
36
+
37
+_USER = os.environ['USER']
38
+# the file that stores the data
39
+_RRD_FILE = "/home/%s/database/powerData.rrd" % _USER
40
+
41
+   ### DEFINE DATABASE SIZE AND GRANULARITY
42
+
43
+_RRD_SIZE_IN_DAYS = 740 # days
44
+_1YR_RRA_STEPS_PER_DAY = 1440
45
+_DATABASE_UPDATE_INTERVAL = 30
46
+
47
+def createRrdFile():
48
+    """Create the rrd file if it does not exist.
49
+       Parameters: none
50
+       Returns: True, if successful
51
+    """
52
+
53
+    if os.path.exists(_RRD_FILE):
54
+        print("power database already exists")
55
+        return True
56
+
57
+     ## Calculate database size
58
+ 
59
+    heartBeat = 2 * _DATABASE_UPDATE_INTERVAL
60
+    rra1yrNumPDP =  int(round(86400 / (_1YR_RRA_STEPS_PER_DAY * \
61
+                    _DATABASE_UPDATE_INTERVAL)))
62
+    rrd24hrNumRows = int(round(86400 / _DATABASE_UPDATE_INTERVAL))
63
+    rrd1yearNumRows = _1YR_RRA_STEPS_PER_DAY * _RRD_SIZE_IN_DAYS
64
+       
65
+    strFmt = ("rrdtool create %s --start now-1day --step %s "
66
+              "DS:CUR:GAUGE:%s:U:U DS:VOLT:GAUGE:%s:U:U "
67
+              "DS:PWR:GAUGE:%s:U:U DS:BTMP:GAUGE:%s:U:U "
68
+              "DS:ATMP:GAUGE:%s:U:U "
69
+              "RRA:LAST:0.5:1:%s RRA:LAST:0.5:%s:%s")
70
+
71
+    strCmd = strFmt % (_RRD_FILE, _DATABASE_UPDATE_INTERVAL,           \
72
+                heartBeat, heartBeat, heartBeat, heartBeat, heartBeat, \
73
+                rrd24hrNumRows, rra1yrNumPDP, rrd1yearNumRows)
74
+
75
+    print("creating power database...\n\n%s\n" % strCmd)
76
+
77
+    # Spawn a sub-shell and run the command
78
+    try:
79
+        subprocess.check_output(strCmd, stderr=subprocess.STDOUT, \
80
+                                shell=True)
81
+    except subprocess.CalledProcessError as exError:
82
+        print("rrdtool create failed: %s" % (exError.output))
83
+        return False
84
+    return True
85
+##end def
86
+
87
+def main():
88
+    createRrdFile()
89
+## end def
90
+
91
+if __name__ == '__main__':
92
+    main()
93
+        
0 94
new file mode 100755
... ...
@@ -0,0 +1,200 @@
1
+#!/usr/bin/python3 -u
2
+#
3
+# Module: ina260.py
4
+#
5
+# Description: This module acts as an interface between the INA260 sensor
6
+# and downstream applications that use the data.  Class methods get
7
+# current, voltage, and power data from the INA260 sensor.  It acts as a
8
+# library module that can be imported into and called from other Python
9
+# programs.
10
+#
11
+# Copyright 2021 Jeff Owrey
12
+#    This program is free software: you can redistribute it and/or modify
13
+#    it under the terms of the GNU General Public License as published by
14
+#    the Free Software Foundation, either version 3 of the License, or
15
+#    (at your option) any later version.
16
+#
17
+#    This program is distributed in the hope that it will be useful,
18
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
19
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20
+#    GNU General Public License for more details.
21
+#
22
+#    You should have received a copy of the GNU General Public License
23
+#    along with this program.  If not, see http://www.gnu.org/license.
24
+#
25
+# Revision History
26
+#   * v10 released 01 June 2021 by J L Owrey; first release
27
+#
28
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
29
+
30
+# Import the I2C interface library
31
+import smbus
32
+import time
33
+
34
+# Define Device Registers
35
+CONFIG_REG = 0x0
36
+ID_REG = 0xFE
37
+CUR_REG = 0x1
38
+VOLT_REG = 0x2
39
+PWR_REG = 0x3
40
+
41
+# Define default sm bus address.
42
+DEFAULT_BUS_ADDRESS = 0x40
43
+DEFAULT_BUS_NUMBER = 1
44
+
45
+# Define the default sensor configuration.  See the INA260 data sheet
46
+# for meaning of each bit.  The following bytes are written to the
47
+# configuration register
48
+#     byte 1: 11100000
49
+#     byte 2: 00100111
50
+DEFAULT_CONFIG = 0xE027
51
+
52
+class ina260:
53
+    # Initialize the INA260 sensor at the supplied address (default
54
+    # address is 0x40), and supplied bus (default is 1).  Creates
55
+    # a new SMBus object for each instance of this class.  Writes
56
+    # configuration data (two bytes) to the INA260 configuration
57
+    # register.
58
+    def __init__(self, sAddr=DEFAULT_BUS_ADDRESS,
59
+                       sbus=DEFAULT_BUS_NUMBER,
60
+                       config=DEFAULT_CONFIG,
61
+                       debug=False):
62
+        # Instantiate a smbus object.
63
+        self.sensorAddr = sAddr
64
+        self.bus = smbus.SMBus(sbus)
65
+        self.debugMode = debug
66
+
67
+        # Initialize INA260 sensor.  
68
+        initData = [(config >> 8), (config & 0x00FF)]
69
+        self.bus.write_i2c_block_data(self.sensorAddr, CONFIG_REG, initData)
70
+
71
+        if self.debugMode:
72
+            data = self.getInfo()
73
+            print(self)
74
+            print("manufacturer ID: %s %s\n"\
75
+                  "INA260 configuration register: %s %s\n" % data)
76
+    ## end def
77
+
78
+    def getInfo(self):
79
+        # Read manufacture identification data.
80
+        mfcid = self.bus.read_i2c_block_data(self.sensorAddr, ID_REG, 2)
81
+        mfcidB1 = format(mfcid[0], "08b")
82
+        mfcidB2 = format(mfcid[1], "08b")
83
+        # Read configuration data.
84
+        config = self.bus.read_i2c_block_data(self.sensorAddr, CONFIG_REG, 2)
85
+        configB1 = format(config[0], "08b")
86
+        configB2 = format(config[1], "08b")
87
+        return (mfcidB1, mfcidB2, configB1, configB2)
88
+    ## end def
89
+
90
+    def getCurrentReg(self):
91
+        # Read current register and return raw binary data for test and
92
+        # debug.
93
+        data = self.bus.read_i2c_block_data(self.sensorAddr, CUR_REG, 2)
94
+        dataB1 = format(data[0], "08b")
95
+        dataB2 = format(data[1], "08b")
96
+        return (dataB1, dataB2)
97
+    ## end def
98
+
99
+    def getCurrent(self):
100
+        # Get the current data from the sensor.
101
+        # INA260 returns the data in two bytes formatted as follows
102
+        #        -------------------------------------------------
103
+        #    bit |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
104
+        #        -------------------------------------------------
105
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
106
+        #        -------------------------------------------------
107
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
108
+        #        -------------------------------------------------
109
+        # The current is returned in d15-d0, a two's complement,
110
+        # 16 bit number.  This means that d15 is the sign bit.        
111
+        data=self.bus.read_i2c_block_data(self.sensorAddr, CUR_REG, 2)
112
+
113
+        if self.debugMode:
114
+            dataB1 = format(data[0], "08b")
115
+            dataB2 = format(data[1], "08b")
116
+            print("current register: %s %s" % (dataB1, dataB2))
117
+
118
+        # Format into a 16 bit word.
119
+        bdata = data[0] << 8 | data[1]
120
+        # Convert from two's complement to integer.
121
+        # If d15 is 1, the the number is a negative two's complement
122
+        # number.  The absolute value is 2^16 - 1 minus the value
123
+        # of d15-d0 taken as a positive number.
124
+        if bdata > 0x7FFF:
125
+            bdata = -(0xFFFF - bdata) # 0xFFFF equals 2^16 - 1
126
+        # Convert integer data to mAmps.
127
+        mAmps = bdata * 1.25  # LSB is 1.25 mA
128
+        return mAmps
129
+    ## end def
130
+
131
+    def getVoltageReg(self):
132
+        # Read voltage register and return raw binary data for test
133
+        # and debug.
134
+        data = self.bus.read_i2c_block_data(self.sensorAddr, VOLT_REG, 2)
135
+        dataB1 = format(data[0], "08b")
136
+        dataB2 = format(data[1], "08b")
137
+        return (dataB1, dataB2)
138
+    ## end def
139
+
140
+    def getVoltage(self):
141
+        # Get the voltage data from the sensor.
142
+        # INA260 returns the data in two bytes formatted as follows
143
+        #        -------------------------------------------------
144
+        #    bit |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
145
+        #        -------------------------------------------------
146
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
147
+        #        -------------------------------------------------
148
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
149
+        #        -------------------------------------------------
150
+        # The voltage is returned in d15-d0 as an unsigned integer.
151
+        data=self.bus.read_i2c_block_data(self.sensorAddr, VOLT_REG, 2)
152
+
153
+        if self.debugMode:
154
+            dataB1 = format(data[0], "08b")
155
+            dataB2 = format(data[1], "08b")
156
+            print("voltage register: %s %s" % (dataB1, dataB2))
157
+
158
+        # Convert data to volts.
159
+        volts = (data[0] << 8 | data[1]) * 0.00125 # LSB is 1.25 mV
160
+        return volts
161
+    ## end def
162
+
163
+    def getPower(self):
164
+        # Get the wattage data from the sensor.
165
+        # INA260 returns the data in two bytes formatted as follows
166
+        #        -------------------------------------------------
167
+        #    bit | 7   |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
168
+        #        -------------------------------------------------
169
+        # byte 1 | d15 | d14 | d13 | d12 | d11 | d10 | d9  | d8  |
170
+        #        -------------------------------------------------
171
+        # byte 2 | d7  | d6  | d5  | d4  | d3  |  d2 | d1  | d0  |
172
+        #        -------------------------------------------------
173
+        # The wattage is returned in d15-d0 as an unsigned integer.
174
+        data=self.bus.read_i2c_block_data(self.sensorAddr, PWR_REG, 2)
175
+
176
+        if self.debugMode:
177
+            dataB1 = format(data[0], "08b")
178
+            dataB2 = format(data[1], "08b")
179
+            print("power register: %s %s" % (dataB1, dataB2))
180
+
181
+        # Convert data to milliWatts. 
182
+        mW = (data[0] << 8 | data[1]) * 10.0  # LSB is 10.0 mW
183
+        return mW
184
+   ## end def
185
+## end class
186
+
187
+def test():
188
+    # Initialize the smbus and INA260 sensor.
189
+    pwr1 = ina260(0x40, 1, debug=True)
190
+    # Print out sensor values.
191
+    while True:
192
+        print("%6.2f mA" % pwr1.getCurrent())
193
+        print("%6.2f V" % pwr1.getVoltage())
194
+        print("%6.2f mW\n" % pwr1.getPower())
195
+        time.sleep(2)
196
+## end def
197
+
198
+if __name__ == '__main__':
199
+    test()
200
+
0 201
new file mode 100755
... ...
@@ -0,0 +1,645 @@
1
+#!/usr/bin/python3 -u
2
+# The -u option above turns off block buffering of python output. This 
3
+# assures that each error message gets individually printed to the log file.
4
+#
5
+# Module: nodepowerAgent.py
6
+#
7
+# Description: This module acts as an agent between the mesh network and
8
+# node power and enviromental sensors.  The agent periodically polls the
9
+# sensors and processes the data returned from the sensors, including
10
+#     - conversion of data items
11
+#     - update a round robin (rrdtool) database with the sensor data
12
+#     - periodically generate graphic charts for display in html documents
13
+#     - write the processed node status to a JSON file for use by html
14
+#       documents
15
+#
16
+# Copyright 2021 Jeff Owrey
17
+#    This program is free software: you can redistribute it and/or modify
18
+#    it under the terms of the GNU General Public License as published by
19
+#    the Free Software Foundation, either version 3 of the License, or
20
+#    (at your option) any later version.
21
+#
22
+#    This program is distributed in the hope that it will be useful,
23
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
24
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25
+#    GNU General Public License for more details.
26
+#
27
+#    You should have received a copy of the GNU General Public License
28
+#    along with this program.  If not, see http://www.gnu.org/license.
29
+#
30
+# Revision History
31
+#   * v10 released 01 June 2021 by J L Owrey; first release
32
+#   * v11 released 02 July 2021 by J L Owrey; improved sensor fault
33
+#     handling; improved code readability
34
+#   * v12 released 06 July 2021 by J L Owrey; improved debug mode
35
+#     handling; debug mode state now passed to sensor object constructors
36
+#
37
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
38
+
39
+# Import required python libraries.
40
+import os
41
+import sys
42
+import signal
43
+import subprocess
44
+import multiprocessing
45
+import time
46
+import json
47
+
48
+# Import sensor libraries.
49
+import ina260 # power sensor
50
+import tmp102 # temperature sensor
51
+
52
+# Import custom libraries
53
+import smsalert
54
+
55
+    ### ENVIRONMENT ###
56
+
57
+_USER = os.environ['USER']
58
+_HOSTNAME = os.uname()[1]
59
+
60
+    ### SMS RECIPIENTS ###
61
+
62
+_SMS_CALLSIGN = '{your callsign}'
63
+_SMS_PASSCODE = '{your passcode}'
64
+_SMS_PHONE_NUMBER = '{your phone number}'
65
+
66
+    ### SENSOR BUS ADDRESSES ###
67
+
68
+# Set bus addresses of sensors.
69
+_PWR_SENSOR_ADDR = 0X40
70
+_BAT_TMP_SENSOR_ADDR = 0x48
71
+_AMB_TMP_SENSOR_ADDR = 0x4B
72
+# Set bus selector.
73
+_BUS_NUMBER = 1
74
+
75
+    ### FILE AND FOLDER LOCATIONS ###
76
+
77
+# folder to contain html
78
+_DOCROOT_PATH = "/home/%s/public_html/power/" % _USER
79
+# folder to contain charts and output data file
80
+_CHARTS_DIRECTORY = _DOCROOT_PATH + "dynamic/"
81
+# location of JSON output data file
82
+_OUTPUT_DATA_FILE = _DOCROOT_PATH + "dynamic/powerData.js"
83
+# database that stores node data
84
+_RRD_FILE = "/home/%s/database/powerData.rrd" % _USER
85
+
86
+    ### GLOBAL CONSTANTS ###
87
+
88
+# sensor data request interval in seconds
89
+_DEFAULT_SENSOR_POLLING_INTERVAL = 2
90
+# rrdtool database update interval in seconds
91
+_DATABASE_UPDATE_INTERVAL = 30
92
+# max number of failed attempts to get sensor data
93
+_MAX_FAILED_DATA_REQUESTS = 2
94
+
95
+# chart update interval in seconds
96
+_CHART_UPDATE_INTERVAL = 600
97
+# standard chart width in pixels
98
+_CHART_WIDTH = 600
99
+# standard chart height in pixels
100
+_CHART_HEIGHT = 150
101
+# chart average line color
102
+_AVERAGE_LINE_COLOR = '#006600'
103
+# low voltage alert threshold
104
+_DEFAULT_CRITICAL_LOW_VOLTAGE = 12.0
105
+
106
+   ### GLOBAL VARIABLES ###
107
+
108
+# Sensor instance objects.
109
+power1 = None
110
+battemp = None
111
+ambtemp = None
112
+sms = None
113
+
114
+# turns on or off extensive debugging messages
115
+debugMode = False
116
+verboseMode = False
117
+
118
+# frequency of data requests to sensors
119
+dataRequestInterval = _DEFAULT_SENSOR_POLLING_INTERVAL
120
+# how often charts get updated
121
+chartUpdateInterval = _CHART_UPDATE_INTERVAL
122
+# number of failed attempts to get sensor data
123
+failedUpdateCount = 0
124
+# sensor status
125
+deviceOnline = False
126
+# sms message sent status
127
+bSMSmsgSent = False
128
+
129
+  ###  PRIVATE METHODS  ###
130
+
131
+def getTimeStamp():
132
+    """
133
+    Get the local time and format as a text string.
134
+    Parameters: none
135
+    Returns: string containing the time stamp
136
+    """
137
+    return time.strftime( "%m/%d/%Y %T", time.localtime() )
138
+## end def
139
+
140
+def getEpochSeconds(sTime):
141
+    """
142
+    Convert the time stamp to seconds since 1/1/1970 00:00:00.
143
+    Parameters: 
144
+        sTime - the time stamp to be converted must be formatted
145
+                   as %m/%d/%Y %H:%M:%S
146
+    Returns: epoch seconds
147
+    """
148
+    try:
149
+        t_sTime = time.strptime(sTime, '%m/%d/%Y %H:%M:%S')
150
+    except Exception as exError:
151
+        print('%s getEpochSeconds: %s' % (getTimeStamp(), exError))
152
+        return None
153
+    tSeconds = int(time.mktime(t_sTime))
154
+    return tSeconds
155
+## end def
156
+
157
+def setStatusToOffline():
158
+    """Set the detected status of the device to
159
+       "offline" and inform downstream clients by removing input
160
+       and output data files.
161
+       Parameters: none
162
+       Returns: nothing
163
+    """
164
+    global deviceOnline
165
+
166
+    # Inform downstream clients by removing output data file.
167
+    if os.path.exists(_OUTPUT_DATA_FILE):
168
+       os.remove(_OUTPUT_DATA_FILE)
169
+    # If the sensor or  device was previously online, then send
170
+    # a message that we are now offline.
171
+    if deviceOnline:
172
+        print('%s device offline' % getTimeStamp())
173
+    deviceOnline = False
174
+##end def
175
+
176
+def terminateAgentProcess(signal, frame):
177
+    """Send a message to log when the agent process gets killed
178
+       by the operating system.  Inform downstream clients
179
+       by removing input and output data files.
180
+       Parameters:
181
+           signal, frame - dummy parameters
182
+       Returns: nothing
183
+    """
184
+    print('%s terminating agent process' % getTimeStamp())
185
+    setStatusToOffline()
186
+    sys.exit(0)
187
+##end def
188
+
189
+  ###  PUBLIC METHODS  ###
190
+
191
+def getSensorData(dData):
192
+    """
193
+    Poll sensors for data. Store the data in a dictionary object for
194
+    use by other subroutines.  The dictionary object passed in should
195
+    an empty dictionary, i.e., dData = { }.
196
+    Parameters: dData - a dictionary object to contain the sensor data
197
+                dSensors - a dictionary containing sensor objects
198
+    Returns: True if successful, False otherwise
199
+    """
200
+    dData["time"] = getTimeStamp()
201
+ 
202
+    try:
203
+        dData["current"] = power1.getCurrent()
204
+        dData["voltage"] = power1.getVoltage()
205
+        dData["power"] =   power1.getPower()
206
+        dData["battemp"] = battemp.getTempF()
207
+        dData["ambtemp"] = ambtemp.getTempF()
208
+    except Exception as exError:
209
+        print("%s sensor error: %s" % (getTimeStamp(), exError))
210
+        return False
211
+
212
+    dData['chartUpdateInterval'] = chartUpdateInterval
213
+
214
+    return True
215
+## end def
216
+
217
+def convertData(dData):
218
+    """
219
+    Converts data items and verifies threshold crossings.
220
+    Parameters: dData - a dictionary object that contains the sensor data
221
+    Returns: True if successful, False otherwise
222
+    """
223
+    global bSMSmsgSent
224
+
225
+    if not bSMSmsgSent and dData["voltage"] <= _DEFAULT_CRITICAL_LOW_VOLTAGE:
226
+        # Format a text alert message.
227
+        message = "%s %s low voltage alert: %s volts" % \
228
+                  (getTimeStamp(), _HOSTNAME, dData["voltage"])
229
+        print(message)
230
+        # Send the text alert to recipient phone numbers.
231
+        sms.sendSMS(_SMS_PHONE_NUMBER, message)
232
+        bSMSmsgSent = True
233
+    elif bSMSmsgSent and dData["voltage"] > _DEFAULT_CRITICAL_LOW_VOLTAGE:
234
+        # Format a text alert message.
235
+        message = "%s %s voltage normal: %s volts" % \
236
+                  (getTimeStamp(), _HOSTNAME, dData["voltage"])
237
+        print(message)
238
+        # Send the text alert to recipient phone numbers.
239
+        sms.sendSMS(_SMS_PHONE_NUMBER, message)
240
+        bSMSmsgSent = False
241
+    ## end if
242
+    return True
243
+## end def
244
+
245
+def writeOutputFile(dData):
246
+    """
247
+    Write sensor data items to the output data file, formatted as 
248
+    a Javascript file.  This file may then be requested and used by
249
+    by downstream clients, for instance, an HTML document.
250
+    Parameters:
251
+        dData - a dictionary containing the data to be written
252
+                   to the output data file
253
+        Returns: True if successful, False otherwise
254
+    """
255
+    # Write a JSON formatted file for use by html clients.  The following
256
+    # data items are sent to the client file.
257
+    #    * The last database update date and time
258
+    #    * The data request interval
259
+    #    * The sensor values
260
+
261
+    # Create a JSON formatted string from the sensor data.
262
+    jsData = json.loads("{}")
263
+    try:
264
+        for key in dData:
265
+            jsData.update({key:dData[key]})
266
+        sData = "[%s]" % json.dumps(jsData)
267
+    except Exception as exError:
268
+        print("%s writeOutputFile: %s" % (getTimeStamp(), exError))
269
+        return False
270
+
271
+    if debugMode:
272
+        print(sData)
273
+
274
+    # Write the JSON formatted data to the output data file.
275
+
276
+    try:
277
+        fc = open(_OUTPUT_DATA_FILE, "w")
278
+        fc.write(sData)
279
+        fc.close()
280
+    except Exception as exError:
281
+        print("%s write output file failed: %s" % \
282
+              (getTimeStamp(), exError))
283
+        return False
284
+
285
+    return True
286
+## end def
287
+
288
+def setStatus(updateSuccess):
289
+    """Detect if device is offline or not available on
290
+       the network. After a set number of attempts to get data
291
+       from the device set a flag that the device is offline.
292
+       Parameters:
293
+           updateSuccess - a boolean that is True if data request
294
+                           successful, False otherwise
295
+       Returns: nothing
296
+    """
297
+    global failedUpdateCount, deviceOnline
298
+
299
+    if updateSuccess:
300
+        failedUpdateCount = 0
301
+        # Set status and send a message to the log if the device
302
+        # previously offline and is now online.
303
+        if not deviceOnline:
304
+            print('%s device online' % getTimeStamp())
305
+            deviceOnline = True
306
+        return
307
+    else:
308
+        # The last attempt failed, so update the failed attempts
309
+        # count.
310
+        failedUpdateCount += 1
311
+
312
+    if failedUpdateCount == _MAX_FAILED_DATA_REQUESTS:
313
+        # Max number of failed data requests, so set
314
+        # device status to offline.
315
+        setStatusToOffline()
316
+##end def
317
+
318
+    ### DATABASE FUNCTIONS ###
319
+
320
+def updateDatabase(dData):
321
+    """
322
+    Update the rrdtool database by executing an rrdtool system command.
323
+    Format the command using the data extracted from the sensors.
324
+    Parameters: dData - dictionary object containing data items to be
325
+                        written to the rr database file
326
+    Returns: True if successful, False otherwise
327
+    """
328
+ 
329
+    epochTime = getEpochSeconds(dData['time'])
330
+
331
+    # Format the rrdtool update command.
332
+    strFmt = "rrdtool update %s %s:%s:%s:%s:%s:%s"
333
+    strCmd = strFmt % (_RRD_FILE, epochTime, dData['current'], \
334
+             dData['voltage'], dData['power'], dData['battemp'], \
335
+             dData['ambtemp'])
336
+
337
+    if debugMode:
338
+        print("%s" % strCmd) # DEBUG
339
+
340
+    # Run the command as a subprocess.
341
+    try:
342
+        subprocess.check_output(strCmd, shell=True, \
343
+            stderr=subprocess.STDOUT)
344
+    except subprocess.CalledProcessError as exError:
345
+        print("%s: rrdtool update: %s" % \
346
+            (getTimeStamp(), exError.output))
347
+        return False
348
+
349
+    if verboseMode and not debugMode:
350
+        print("database update successful")
351
+
352
+    return True
353
+## end def
354
+
355
+def createGraph(fileName, dataItem, gLabel, gTitle, gStart,
356
+                lower=0, upper=0, trendLine=0, scaleFactor=1,
357
+                autoScale=True, alertLine=""):
358
+    """
359
+    Uses rrdtool to create a graph of specified sensor data item.
360
+    Parameters:
361
+        fileName - name of file containing the graph
362
+        dataItem - data item to be graphed
363
+        gLabel - string containing a graph label for the data item
364
+        gTitle - string containing a title for the graph
365
+        gStart - beginning time of the graphed data
366
+        lower - lower bound for graph ordinate #NOT USED
367
+        upper - upper bound for graph ordinate #NOT USED
368
+        trendLine 
369
+            0, show only graph data
370
+            1, show only a trend line
371
+            2, show a trend line and the graph data
372
+        scaleFactor - amount to pre-scale the data before charting
373
+            the data [default=1]
374
+        autoScale - if True, then use vertical axis auto scaling
375
+            (lower and upper parameters must be zero)
376
+        alertLine - value for which to print a critical
377
+            low voltage alert line on the chart. If not provided
378
+            alert line will not be printed.
379
+    Returns: True if successful, False otherwise
380
+    """
381
+    gPath = _CHARTS_DIRECTORY + fileName + ".png"
382
+    trendWindow = { 'end-1day': 7200,
383
+                    'end-4weeks': 172800,
384
+                    'end-12months': 604800 }
385
+ 
386
+    # Format the rrdtool graph command.
387
+
388
+    # Set chart start time, height, and width.
389
+    strCmd = "rrdtool graph %s -a PNG -s %s -e now -w %s -h %s " \
390
+             % (gPath, gStart, _CHART_WIDTH, _CHART_HEIGHT)
391
+   
392
+    # Set the range and scaling of the chart y-axis.
393
+    if lower < upper:
394
+        strCmd  +=  "-l %s -u %s -r " % (lower, upper)
395
+    elif autoScale:
396
+        strCmd += "-A "
397
+    strCmd += "-Y "
398
+
399
+    # Set the chart ordinate label and chart title. 
400
+    strCmd += "-v %s -t %s " % (gLabel, gTitle)
401
+ 
402
+    # Show the data, or a moving average trend line over
403
+    # the data, or both.
404
+    strCmd += "DEF:rSeries=%s:%s:LAST " % (_RRD_FILE, dataItem)
405
+    strCmd += "CDEF:dSeries=rSeries,%s,/ " % (scaleFactor)
406
+
407
+    if trendLine == 0:
408
+        strCmd += "LINE1:dSeries#0400ff "
409
+    elif trendLine == 1:
410
+        strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
411
+                  % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
412
+    elif trendLine == 2:
413
+        strCmd += "LINE1:dSeries#0400ff "
414
+        strCmd += "CDEF:smoothed=dSeries,%s,TREND LINE2:smoothed%s " \
415
+                  % (trendWindow[gStart], _AVERAGE_LINE_COLOR)
416
+
417
+    if alertLine != "":
418
+        strCmd += "HRULE:%s#FF0000:Critical\ Low\ Voltage " % (alertLine)
419
+     
420
+    if debugMode:
421
+        print("%s\n" % strCmd) # DEBUG
422
+    
423
+    # Run the formatted rrdtool command as a subprocess.
424
+    try:
425
+        result = subprocess.check_output(strCmd, \
426
+                     stderr=subprocess.STDOUT,   \
427
+                     shell=True)
428
+    except subprocess.CalledProcessError as exError:
429
+        print("%s rrdtool graph: %s" % \
430
+              (getTimeStamp(), exError.output))
431
+        return False
432
+
433
+    if verboseMode and not debugMode:
434
+        print("rrdtool graph: %s" % result.decode('utf-8'))
435
+    return True
436
+
437
+## end def
438
+
439
+def generateGraphs():
440
+    """
441
+    Generate graphs for display in html documents.
442
+    Parameters: none
443
+    Returns: nothing
444
+    """
445
+
446
+    # 24 hour stock charts
447
+
448
+    createGraph('24hr_current', 'CUR', 'Amps',
449
+                'Current\ -\ Last\ 24\ Hours', 'end-1day', \
450
+                0, 0, 2, 1000)
451
+    createGraph('24hr_voltage', 'VOLT', 'Volts',
452
+                'Voltage\ -\ Last\ 24\ Hours', 'end-1day', \
453
+                9, 15, 0, 1, True, 11)
454
+    createGraph('24hr_power', 'PWR', 'Watts', 
455
+                'Power\ -\ Last\ 24\ Hours', 'end-1day', \
456
+                0, 0, 2, 1000)
457
+    createGraph('24hr_battemp', 'BTMP', 'deg\ F', 
458
+                'Battery\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
459
+                0, 0, 0)
460
+    createGraph('24hr_ambtemp', 'ATMP', 'deg\ F', 
461
+                'Ambient\ Temperature\ -\ Last\ 24\ Hours', 'end-1day', \
462
+                0, 0, 0)
463
+
464
+    # 4 week stock charts
465
+
466
+    createGraph('4wk_current', 'CUR', 'Amps',
467
+                'Current\ -\ Last\ 4\ Weeks', 'end-4weeks', \
468
+                0, 0, 2, 1000)
469
+    createGraph('4wk_voltage', 'VOLT', 'Volts',
470
+                'Voltage\ -\ Last\ 4\ Weeks', 'end-4weeks', \
471
+                9, 15, 0, 1, True, 11)
472
+    createGraph('4wk_power', 'PWR', 'Watts', 
473
+                'Power\ -\ Last\ 4\ Weeks', 'end-4weeks', \
474
+                0, 0, 2, 1000)
475
+    createGraph('4wk_battemp', 'BTMP', 'deg\ F', 
476
+                'Battery\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
477
+                0, 0, 2)
478
+    createGraph('4wk_ambtemp', 'ATMP', 'deg\ F', 
479
+                'Ambient\ Temperature\ -\ Last\ 4\ Weeks', 'end-4weeks', \
480
+                0, 0, 2)
481
+
482
+    # 12 month stock charts
483
+
484
+    createGraph('12m_current', 'CUR', 'Amps',
485
+                'Current\ -\ Past\ Year', 'end-12months', \
486
+                0, 0, 2, 1000)
487
+    createGraph('12m_voltage', 'VOLT', 'Volts',
488
+                'Voltage\ -\ Past\ Year', 'end-12months', \
489
+                9, 15, 0, 1, True, 11)
490
+    createGraph('12m_power', 'PWR', 'Watts', 
491
+                'Power\ -\ Past\ Year', 'end-12months', \
492
+                0, 0, 2, 1000)
493
+    createGraph('12m_battemp', 'BTMP', 'deg\ F', 
494
+                'Battery\ Temperature\ -\ Past\ Year', 'end-12months', \
495
+                0, 0, 2)
496
+    createGraph('12m_ambtemp', 'ATMP', 'deg\ F', 
497
+                'Ambient\ Temperature\ -\ Past\ Year', 'end-12months', \
498
+                0, 0, 2)
499
+## end def
500
+
501
+def getCLarguments():
502
+    """
503
+    Get command line arguments.  There are three possible arguments
504
+        -d turns on debug mode
505
+        -v turns on verbose mode
506
+        -p sets the sensor query period
507
+        -c sets the chart update period
508
+    Returns: nothing
509
+    """
510
+    global debugMode, verboseMode, dataRequestInterval, chartUpdateInterval
511
+
512
+    index = 1
513
+    while index < len(sys.argv):
514
+        if sys.argv[index] == '-v':
515
+            verboseMode = True
516
+        elif sys.argv[index] == '-d':
517
+            debugMode = True
518
+            verboseMode = True
519
+        elif sys.argv[index] == '-p':
520
+            try:
521
+                dataRequestInterval = abs(int(sys.argv[index + 1]))
522
+            except:
523
+                print("invalid sensor query period")
524
+                exit(-1)
525
+            index += 1
526
+        elif sys.argv[index] == '-c':
527
+            try:
528
+                chartUpdateInterval = abs(int(sys.argv[index + 1]))
529
+            except:
530
+                print("invalid chart update period")
531
+                exit(-1)
532
+            index += 1
533
+        else:
534
+            cmd_name = sys.argv[0].split('/')
535
+            print("Usage: %s [-d | v] [-p seconds] [-c seconds]" \
536
+                  % cmd_name[-1])
537
+            exit(-1)
538
+        index += 1
539
+##end def
540
+
541
+def main_setup():
542
+    """
543
+    Handles timing of events and acts as executive routine managing
544
+    all other functions.
545
+    Parameters: none
546
+    Returns: nothing
547
+    """
548
+    global power1, battemp, ambtemp, sms
549
+
550
+    signal.signal(signal.SIGTERM, terminateAgentProcess)
551
+    signal.signal(signal.SIGINT, terminateAgentProcess)
552
+
553
+    # Log agent process startup time.
554
+    print('===================\n'\
555
+          '%s starting up node power agent process' % \
556
+                  (getTimeStamp())
557
+
558
+    ## Get command line arguments.
559
+    getCLarguments()
560
+
561
+    ## Exit with error if rrdtool database does not exist.
562
+    if not os.path.exists(_RRD_FILE):
563
+        print('rrdtool database does not exist\n' \
564
+              'use createPowerRrd script to ' \
565
+              'create rrdtool database\n')
566
+        exit(1)
567
+
568
+    # Create sensor objects.  This also initializes each sensor.
569
+    power1 = ina260.ina260(_PWR_SENSOR_ADDR, _BUS_NUMBER,
570
+                            debug=debugMode)
571
+    battemp = tmp102.tmp102(_BAT_TMP_SENSOR_ADDR, _BUS_NUMBER,
572
+                            debug=debugMode)
573
+    ambtemp = tmp102.tmp102(_AMB_TMP_SENSOR_ADDR, _BUS_NUMBER,
574
+                            debug=debugMode)
575
+    # Create instance of SMS alert class
576
+    sms = smsalert.smsalert(_SMS_CALLSIGN, _SMS_PASSCODE, debug=debugMode)
577
+
578
+## end def
579
+
580
+def main_loop():
581
+    # last time output JSON file updated
582
+    lastDataRequestTime = -1
583
+    # last time charts generated
584
+    lastChartUpdateTime = - 1
585
+    # last time the rrdtool database updated
586
+    lastDatabaseUpdateTime = -1
587
+
588
+    ### MAIN LOOP ###
589
+
590
+    while True:
591
+
592
+        currentTime = time.time() # get current time in seconds
593
+
594
+        # Every data request interval read the sensors and process the
595
+        # data from the sensors.
596
+        if currentTime - lastDataRequestTime > dataRequestInterval:
597
+            lastDataRequestTime = currentTime
598
+            dData = {}
599
+
600
+            # Get the data from the sensors.
601
+            result = getSensorData(dData)
602
+ 
603
+            # If get data successful, convert the data and verify thresholds.
604
+            if result:
605
+                result = convertData(dData)
606
+
607
+            # If convert data successful, write data to data files.
608
+            if result:
609
+                result = writeOutputFile(dData)
610
+
611
+            # At the rrdtool database update interval, update the database.
612
+            if result and (currentTime - lastDatabaseUpdateTime > \
613
+                           _DATABASE_UPDATE_INTERVAL):   
614
+                lastDatabaseUpdateTime = currentTime
615
+                ## Update the round robin database with the parsed data.
616
+                result = updateDatabase(dData)
617
+
618
+            setStatus(result)
619
+
620
+        # At the chart generation interval, generate charts.
621
+        if currentTime - lastChartUpdateTime > chartUpdateInterval:
622
+            lastChartUpdateTime = currentTime
623
+            p = multiprocessing.Process(target=generateGraphs, args=())
624
+            p.start()
625
+            
626
+        # Relinquish processing back to the operating system until
627
+        # the next update interval.
628
+
629
+        elapsedTime = time.time() - currentTime
630
+        if verboseMode:
631
+            if result:
632
+                print("update successful: %6f sec\n"
633
+                      % elapsedTime)
634
+            else:
635
+                print("update failed: %6f sec\n"
636
+                      % elapsedTime)
637
+        remainingTime = dataRequestInterval - elapsedTime
638
+        if remainingTime > 0.0:
639
+            time.sleep(remainingTime)
640
+    ## end while
641
+## end def
642
+
643
+if __name__ == '__main__':
644
+    main_setup()
645
+    main_loop()
0 646
new file mode 100755
... ...
@@ -0,0 +1,25 @@
1
+#!/bin/bash
2
+# Starts up the node power agent as a background process
3
+# and redirects output to a log file.
4
+
5
+APP_PATH="/home/$USER/bin"
6
+LOG_PATH="/home/$USER/log"
7
+
8
+AGENT_NAME="[n]pwAgent.py"
9
+
10
+PROCESS_ID="$(ps x | awk -v a=$AGENT_NAME '$7 ~ a {print $1}')"
11
+
12
+if [ -n "$PROCESS_ID" ]; then
13
+  if [ "$1" != "-q" ]; then
14
+    printf "node power agent running [%s]\n" $PROCESS_ID
15
+  fi
16
+else
17
+  printf "starting up node agent\n"
18
+  cd $APP_PATH
19
+  if [ "$1" != "" ]; then
20
+    ./$AGENT_NAME $1
21
+  else
22
+    ./$AGENT_NAME >> $LOG_PATH/npwAgent.log 2>&1 &
23
+  fi
24
+fi
25
+
0 26
new file mode 100755
... ...
@@ -0,0 +1,13 @@
1
+#!/bin/bash
2
+# Stop the node power agent process and clean up environment.
3
+
4
+AGENT_NAME="[n]pwAgent.py"
5
+
6
+PROCESS_ID="$(ps x | awk -v a=$AGENT_NAME '$7 ~ a {print $1}')"
7
+
8
+if [ -n "$PROCESS_ID" ]; then
9
+  printf "killing node power agent [%s]\n" $PROCESS_ID
10
+  kill $PROCESS_ID
11
+else
12
+  echo node power agent not running
13
+fi
0 14
new file mode 100755
... ...
@@ -0,0 +1,79 @@
1
+#!/usr/bin/python3 -u
2
+
3
+# courtsey ruler for editing script - 80 characters max line length
4
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
5
+
6
+import telnetlib
7
+
8
+_DEFAULT_HOST = '{your APRS-IS server hostname}'
9
+_DEFAULT_PORT = '{your APRS-IS message port}'
10
+_DEFAULT_SERVER = '{your APRS-IS server name}'
11
+
12
+class smsalert:
13
+
14
+    def __init__(self, callsign, passcode, host=_DEFAULT_HOST, \
15
+      port=_DEFAULT_PORT, server=_DEFAULT_SERVER, debug=False):
16
+        """
17
+        Initialize an instance of this class.
18
+        Parameters:
19
+          callsign - amateur radio callsign of user (must be verified)
20
+          passcode - passcode for verified callsign
21
+          host - domain name or IP address of APRS-IS server
22
+          port - port on which the APRS-IS server receives messages
23
+          server - APRS service name
24
+          debug - set equal to True for debug output
25
+        Returns: nothing
26
+        """
27
+        # Initialize class instance variables.
28
+        self.callsign = callsign
29
+        self.passcode = passcode
30
+        self.host = host
31
+        self.port = port
32
+        self.server = server
33
+        self.debug = debug
34
+    ## end def
35
+
36
+    def sendSMS(self, phone_number, text_message):
37
+        """
38
+        Sends an SMS text message to the provided phone number.
39
+        Parameters:
40
+          phone_number - phone number to which to send the text message
41
+          text_message - text message to be sent to the provided phone number
42
+        Returns: True if successful, False otherwise
43
+        """
44
+        # Establish network connection to APRS-IS server.
45
+        tn = telnetlib.Telnet(self.host, self.port)
46
+        tn.read_until('# aprsc 2.1.8-gf8824e8')
47
+
48
+        # Login and verify passcode accepted.
49
+        tn.write('user ' + self.callsign + ' pass ' + self.passcode + '\n')
50
+        response = tn.read_until(self.server)
51
+        if self.debug:
52
+            print('response: ' + response[2:])
53
+        if not response.find('verified'):
54
+            print('smsalert error: unverified user')
55
+            del tn
56
+            return False
57
+
58
+        # Format and send SMS message to SMS gateway.
59
+        cmd = '%s>%s::SMSGTE:@%s %s' % \
60
+          (self.callsign, self.server, phone_number, text_message)
61
+        if self.debug:
62
+            print('cmd: ' + cmd)
63
+        tn.write(cmd + '\n')
64
+        del tn
65
+        return True
66
+    ## end def
67
+## end class
68
+
69
+def test_smsalert():
70
+    # Initialize a telnet instance.  Default host, port, and server
71
+    # automatically defined if not included in function call.
72
+    sm = smsalert('{your callsign}', '{your passcode}', debug=True)
73
+
74
+    # Send a text message to a phone number.
75
+    sm.sendSMS('{your phone number}', 'Test message send from smsalert.py')
76
+## end def
77
+
78
+if __name__ == '__main__':
79
+    test_smsalert()
0 80
new file mode 100755
... ...
@@ -0,0 +1,159 @@
1
+#!/usr/bin/python3 -u
2
+#
3
+# Module: tmp102.py
4
+#
5
+# Description: This module acts as an interface between the TMP102 sensor
6
+# and downstream applications that use the data.  Class methods get
7
+# temperature data from the TMP102 sensor. It acts as a library module that
8
+# can be imported into and called from other Python programs.
9
+#
10
+# Copyright 2021 Jeff Owrey
11
+#    This program is free software: you can redistribute it and/or modify
12
+#    it under the terms of the GNU General Public License as published by
13
+#    the Free Software Foundation, either version 3 of the License, or
14
+#    (at your option) any later version.
15
+#
16
+#    This program is distributed in the hope that it will be useful,
17
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19
+#    GNU General Public License for more details.
20
+#
21
+#    You should have received a copy of the GNU General Public License
22
+#    along with this program.  If not, see http://www.gnu.org/license.
23
+#
24
+# Revision History
25
+#   * v10 released 01 June 2021 by J L Owrey; first release
26
+#
27
+#2345678901234567890123456789012345678901234567890123456789012345678901234567890
28
+
29
+# Import the I2C interface library
30
+import smbus
31
+import time
32
+
33
+# Define constants
34
+DEGSYM = u'\xB0'
35
+
36
+# Define TMP102 Device Registers
37
+CONFIG_REG = 0x1
38
+TEMP_REG = 0x0
39
+
40
+# Define default sm bus address.
41
+DEFAULT_BUS_ADDRESS = 0x48
42
+DEFAULT_BUS_NUMBER = 1
43
+
44
+# Define the default sensor configuration.  See the TMP102 data sheet
45
+# for meaning of each bit.  The following bytes are written to the
46
+# configuration register
47
+#     byte 1: 01100000
48
+#     byte 2: 10100000
49
+DEFAULT_CONFIG = 0x60A0
50
+
51
+class tmp102:
52
+
53
+    # Initialize the TMP102 sensor at the supplied address (default
54
+    # address is 0x48), and supplied bus (default is 1).  Creates
55
+    # a new SMBus object for each instance of this class.  Writes
56
+    # configuration data (two bytes) to the TMP102 configuration
57
+    # register.
58
+    def __init__(self, sAddr=DEFAULT_BUS_ADDRESS,
59
+                 sbus=DEFAULT_BUS_NUMBER,
60
+                 config=DEFAULT_CONFIG,
61
+                 debug=False): 
62
+        # Instantiate a smbus object
63
+        self.sensorAddr = sAddr
64
+        self.bus = smbus.SMBus(sbus)
65
+        self.debugMode = debug
66
+ 
67
+        # Initialize TMP102 sensor.  
68
+        initData = [(config >> 8), (config & 0x00FF)]
69
+        self.bus.write_i2c_block_data(self.sensorAddr, CONFIG_REG, initData)
70
+
71
+        if self.debugMode:
72
+            # Read the TMP102 configuration register.
73
+            data = self.getInfo()
74
+            print(self)
75
+            print("configuration register: %s %s\n" % data)
76
+    ## end def
77
+
78
+    # Reads the configuration register (two bytes).
79
+    def getInfo(self):
80
+        # Read configuration data
81
+        config = self.bus.read_i2c_block_data(self.sensorAddr, CONFIG_REG, 2)
82
+        configB1 = format(config[0], "08b")
83
+        configB2 = format(config[1], "08b")
84
+        return (configB1, configB2)
85
+    ## end def
86
+
87
+    def getTempReg(self):
88
+        # Read temperature register and return raw binary data for test
89
+        # and debug.
90
+        data = self.bus.read_i2c_block_data(self.sensorAddr, TEMP_REG, 2)
91
+        dataB1 = format(data[0], "08b")
92
+        dataB2 = format(data[1], "08b")
93
+        return (dataB1, dataB2)
94
+    ## end def
95
+
96
+    # Gets the temperature in binary format and converts to degrees
97
+    # Celsius.
98
+    def getTempC(self):
99
+        # Get temperature data from the sensor.
100
+        # TMP102 returns the data in two bytes formatted as follows
101
+        #        -------------------------------------------------
102
+        #    bit | b7  | b6  | b5  | b4  | b3  | b2  | b1  | b0  |
103
+        #        -------------------------------------------------
104
+        # byte 1 | d11 | d10 | d9  | d8  | d7  | d6  | d5  | d4  |
105
+        #        -------------------------------------------------
106
+        # byte 2 | d3  | d2  | d1  | d0  | 0   |  0  |  0  |  0  |
107
+        #        -------------------------------------------------
108
+        # The temperature is returned in d11-d0, a two's complement,
109
+        # 12 bit number.  This means that d11 is the sign bit.
110
+        data=self.bus.read_i2c_block_data(self.sensorAddr, TEMP_REG, 2)
111
+
112
+        if self.debugMode:
113
+            dataB1 = format(data[0], "08b")
114
+            dataB2 = format(data[1], "08b")
115
+            print("Temperature Reg: %s %s" % (dataB1, dataB2))
116
+
117
+        # Format into a 12 bit word.
118
+        bData = ( data[0] << 8 | data[1] ) >> 4
119
+        # Convert from two's complement to integer.
120
+        # If d11 is 1, the the number is a negative two's complement
121
+        # number.  The absolute value is 2^12 - 1 minus the value
122
+        # of d11-d0 taken as a positive number.
123
+        if bData > 0x7FF:  # all greater values are negative numbers
124
+            bData = -(0xFFF - bData)  # 0xFFF is 2^12 - 1
125
+        # convert integer data to Celsius
126
+        tempC = bData * 0.0625 # LSB is 0.0625 deg Celsius
127
+        return tempC
128
+    ## end def
129
+
130
+    def getTempF(self):
131
+        # Convert Celsius to Fahrenheit using standard formula.
132
+        tempF = (9./5.) * self.getTempC() + 32.
133
+        return tempF
134
+    ## end def
135
+## end class
136
+
137
+def testclass():
138
+    # Initialize the smbus and TMP102 sensor.
139
+    ts1 = tmp102(0x48, 1, debug=True)
140
+    # Print out sensor values.
141
+    bAl = False
142
+    while True:
143
+        tempC = ts1.getTempC()
144
+        tempF = ts1.getTempF()
145
+        if bAl:
146
+            bAl = False
147
+            print("\033[42;30m%6.2f%sC  %6.2f%sF                 \033[m" % \
148
+                  (tempC, DEGSYM, tempF, DEGSYM))
149
+        else:
150
+            bAl = True
151
+            print("%6.2f%sC  %6.2f%sF" % \
152
+                  (tempC, DEGSYM, tempF, DEGSYM))
153
+        time.sleep(2)
154
+    ## end while
155
+## end def
156
+
157
+if __name__ == '__main__':
158
+    testclass()
159
+