smartmetertx2mongo 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.1
2
+ Name: smartmetertx2mongo
3
+ Version: 1.2.0
4
+ Summary: Implementation of smartmetertx to save records to mongodb with config driven via YAML.
5
+ Home-page: https://markizano.net/
6
+ Author: Markizano Draconus
7
+ Author-email: markizano@markizano.net
8
+ License: GNU
@@ -0,0 +1,46 @@
1
+ # Original Class Object
2
+ https://github.com/cmulk/python_smartmetertx
3
+
4
+ # python-smartmetertx
5
+ SmartMeterTX/SmartMeter Texas Python class provides a JSON interface to the electricity usage data available at https://www.smartmetertexas.com.
6
+ You must have an account established at the site.
7
+
8
+ Additions done by [@Markizano](http://github.com/markizano) to support updates since JAN 2024.
9
+ API seems to be the same.
10
+
11
+ More details can be found: https://github.com/mrand/smart_meter_texas
12
+
13
+ Depends on a MongoDB server to be running in the environment of sorts.
14
+
15
+ Will have to later build support for sqlite3 for local DB setup installs
16
+ that require no further software than this package.
17
+
18
+ More documentation in [doc](./doc).
19
+
20
+ Notable files below:
21
+
22
+ # bin/fetchMeterReads.cron.py
23
+ Run this on a CRON to collect meter reads at least once a day to store data offline from the
24
+ SmartMeterTexas.com site.
25
+
26
+ # bin/smtx-server.py
27
+ Run this to start up the local server.
28
+ Configure with `~/.config/smartmetertx/config.yml`.
29
+ Starts on port 7689 by default.
30
+
31
+ Passwords are encrypted using gpg. You can store the PGP armored message block in your configuration
32
+ file and this app will attempt to decrypt using your key (pending you manage the password/key/chain requirements beyond this app).
33
+
34
+ Encrypt the password using:
35
+
36
+ $ echo -en "my-secret-password" | gpg -aer 0x0000
37
+
38
+ Where `0x0000` is the key you want to use for this encryption.
39
+ In this way, sensitive credentials are not stored in plain text in files.
40
+
41
+ Loads a simple web page that can be used to visualize the data you want.
42
+
43
+ Extend as you please from here :)
44
+
45
+ # Screenshots
46
+ ![smtx-sample-page](https://markizano.net/assets/images/smtx-home-page.png)
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ from smartmetertx.smtx2mongo import main
5
+
6
+ if __name__ == '__main__':
7
+ sys.exit( main() )
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ from smartmetertx.server import main
5
+
6
+ if __name__ == '__main__':
7
+ sys.exit( main() )
@@ -0,0 +1,4 @@
1
+ from kizano import Config
2
+ Config.APP_NAME = 'smartmetertx'
3
+ from smartmetertx.api import MeterReader
4
+ from smartmetertx.smtx2mongo import Smtx2Mongo
@@ -0,0 +1,108 @@
1
+ import os
2
+ import json
3
+ import requests
4
+
5
+ from pprint import pformat
6
+ from kizano import getLogger
7
+
8
+ # BEGIN: #StackOverflow
9
+ # @Source: https://stackoverflow.com/a/16630836/2769671
10
+ # These two lines enable debugging at httplib level (requests->urllib3->http.client)
11
+ # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA.
12
+ # The only thing missing will be the response.body which is not logged.
13
+ if os.getenv('DEBUG', False):
14
+ requests_log = getLogger("requests.urllib3", 10)
15
+ requests_log.propagate = True
16
+ # END: #StackOverflow
17
+
18
+
19
+ class MeterReader:
20
+ HOSTNAME = 'www.smartmetertexas.com'
21
+ HOST = f'https://{HOSTNAME}'
22
+ USER_AGENT = 'API Calls (python3; Linux x86_64) Track your own metrics with SmartMeterTX: https://github.com/markizano/smartmetertx'
23
+ TIMEOUT = 30
24
+
25
+ def __init__(self, timeout=10):
26
+ self.log = getLogger(__name__)
27
+ self.logged_in = False
28
+ self.session = requests.Session()
29
+ self.timeout = timeout
30
+ self.session.headers['Authority'] = MeterReader.HOSTNAME
31
+ self.session.headers['Origin'] = MeterReader.HOST
32
+ self.session.headers['Accept'] = 'application/json, text/plain, */*'
33
+ self.session.headers['Accept-Language'] = 'en-US,en;q=0.9'
34
+ self.session.headers['Content-Type'] = 'application/json; charset=UTF-8'
35
+ self.session.headers['dnt'] = '1'
36
+ self.session.headers['sec-ch-ua'] = '".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"'
37
+ self.session.headers['sec-ch-ua-mobile'] = '?0'
38
+ self.session.headers['sec-ch-ua-platform'] = 'Linux'
39
+ self.session.headers['sec-fetch-dest'] = 'empty'
40
+ self.session.headers['sec-fetch-mode'] = 'cors'
41
+ self.session.headers['sec-fetch-site'] = 'same-origin'
42
+ self.session.headers['User-Agent'] = MeterReader.USER_AGENT
43
+
44
+ def api_call(self, url, json):
45
+ '''
46
+ Generic API call that can be made to the site for JSON results back.
47
+ @param url :string: Where to send POST request.
48
+ @param json :object: Data to send to the server.
49
+ @return :object: JSON response back or ERROR
50
+ '''
51
+ self.log.debug(f'MeterReader.api_call(url={url}, json={json})')
52
+ try:
53
+ return self.session.post(
54
+ url=url,
55
+ json=json,
56
+ timeout=self.timeout,
57
+ verify=False
58
+ )
59
+ except Exception as ex:
60
+ self.log.error(repr(ex))
61
+ raise ex
62
+
63
+ def login(self, username, password):
64
+ '''
65
+ Make API call to login and acquire a session token.
66
+ @param username :string: Username or email used to Login to the webpage.
67
+ @param password :string: Password used to login.
68
+ @return :string: The login token that will be used going forward.
69
+ '''
70
+ creds = {
71
+ "username": username,
72
+ "password": password
73
+ }
74
+ url = f"{MeterReader.HOST}/commonapi/user/authenticate"
75
+ r = self.api_call(url, json=creds)
76
+ if r.status_code != 200:
77
+ self.log.error("Login failed.")
78
+ self.log.debug(pformat(r.headers.__dict__))
79
+ self.log.debug(r.text)
80
+ self.log.debug(self.session.cookies.__dict__)
81
+ return False
82
+ else:
83
+ self.token = r.json()['token']
84
+ self.log.info("Login successful!")
85
+ self.log.debug(f"Got \x1b[33m{self.token}\x1b[0m as token.")
86
+ self.session.headers["Authorization"] = f"Bearer {self.token}"
87
+ self.logged_in = True
88
+ return self.token
89
+
90
+ def get_daily_read(self, esiid, start_date, end_date):
91
+ if self.logged_in == False:
92
+ self.log.error("You must login first.")
93
+ return False
94
+
95
+ json = {
96
+ "esiid": esiid,
97
+ "endDate": end_date,
98
+ "startDate": start_date,
99
+ }
100
+ url = f"{MeterReader.HOST}/api/usage/daily"
101
+ r = self.api_call(url, json=json)
102
+ if r.status_code != 200 or "error" in r.text.lower():
103
+ self.log.warning("Failed fetching daily read!")
104
+ self.log.debug(r.text)
105
+ self.log.debug(pformat(r.headers.__dict__))
106
+ return False
107
+ else:
108
+ return r.json()
@@ -0,0 +1,121 @@
1
+
2
+ import os
3
+ import json
4
+ import cherrypy
5
+ import traceback as tb
6
+
7
+ class SmartMeterController(object):
8
+ '''
9
+ Base Controller abstract.
10
+ If a method is common among all controllers it goes here.
11
+ If a method should be implemented by all controllers, it could be included
12
+ here for all controllers to enjoy.
13
+ '''
14
+ ERRORS = {
15
+ 'request-method': 'Request method not allowed.',
16
+ 'accept-json': 'Accept header must be application/json.',
17
+ 'not-json': 'Content-Type must be application/json.',
18
+ 'content-length': 'Content-Length header required.',
19
+ 'empty-body': 'Empty request body.',
20
+ 'invalid-json': 'Invalid JSON: %s',
21
+ }
22
+
23
+ def isValidJSONRequest(self, request, body):
24
+ '''
25
+ Collection of validations against the client request to this server.
26
+ request: cherrypy.request object
27
+ body: The result to attempt to parse as a string.
28
+
29
+ 400: bad request
30
+ 405: method not allowed
31
+ 406: not acceptable
32
+ 411: content-length required
33
+ 415: unsupported media type
34
+ 417: expectation failed
35
+ 422: Unprocessable entity
36
+
37
+ Usage:
38
+
39
+ isValid, errMesgs = self.isValidJSONRequest(request, payload)
40
+ if not isValid:
41
+ return { 'result': False, 'messages': errMesgs }
42
+ '''
43
+ result = True
44
+ mesgs = []
45
+ if request.get('REQUEST_METHOD', '') not in ['GET', 'POST', 'PUT']:
46
+ result = False
47
+ mesgs.append(SmartMeterController.ERRORS['request-method'])
48
+ cherrypy.response.status = 405
49
+ if request.get('REQUEST_URI', '').startswith('/api'):
50
+ accept = request.get('HTTP_ACCEPT', '')
51
+ if ('application/json' not in accept) and ('*/*' not in accept):
52
+ result = False
53
+ mesgs.append(SmartMeterController.ERRORS['accept-json'])
54
+ cherrypy.response.status = 415
55
+ if request['REQUEST_METHOD'] == 'POST':
56
+ if 'application/json' not in request.get('CONTENT_TYPE', ''):
57
+ result = False
58
+ mesgs.append(SmartMeterController.ERRORS['not-json'])
59
+ cherrypy.response.status = 400
60
+ cl = request.get('CONTENT_LENGTH', 0)
61
+ if not cl or int(cl) < 1:
62
+ result = False
63
+ mesgs.append(SmartMeterController.ERRORS['content-length'])
64
+ cherrypy.response.status = 411
65
+ if not body:
66
+ result = False
67
+ mesgs.append(SmartMeterController.ERRORS['empty-body'])
68
+ cherrypy.response.status = 411
69
+ else:
70
+ try:
71
+ j = json.loads(body)
72
+ except ValueError as e:
73
+ result = False
74
+ mesgs.append(SmartMeterController.ERRORS['invalid-json'] % e)
75
+ cherrypy.response.status = 417
76
+ return result, mesgs
77
+
78
+ def returnError(self, err, mesgs):
79
+ '''
80
+ Return a [err] http response code and send the status message as an object.
81
+ Usage:
82
+
83
+ return self.returnError(500, ['no such file or directory'])
84
+ '''
85
+ kwargs = {}
86
+ if not cherrypy.response.status or ( cherrypy.response.status >= 200 and cherrypy.response.status <= 299 ):
87
+ cherrypy.response.status = int(err.get('status', 500))
88
+ if isinstance(mesgs, str):
89
+ mesgs = [mesgs]
90
+ result = {
91
+ 'error': True,
92
+ 'mesgs': err.get('mesgs', mesgs),
93
+ 'value': None,
94
+ }
95
+ if 'DEBUG' in os.environ:
96
+ kwargs['indent'] = 2
97
+ kwargs['sort_keys'] = True
98
+ if err.has_key('e'):
99
+ result['exception'] = tb.format_exc(err['e'])
100
+ return json.dumps(result, **kwargs) + "\n"
101
+
102
+ def returnValue(self, result, value):
103
+ '''
104
+ Returns the value of the result you desire.
105
+ result: Boolean to return as a result of the operation.
106
+ value: object to return describing the result.
107
+ @returns :string: JSON dump of a wrapped object of the input params.
108
+ '''
109
+ if not cherrypy.response.status:
110
+ cherrypy.response.status = 200
111
+ kwargs = {}
112
+ if 'DEBUG' in os.environ:
113
+ kwargs['indent'] = 2
114
+ kwargs['sort_keys'] = True
115
+ return {
116
+ 'error': False,
117
+ 'status': 200,
118
+ 'value': value,
119
+ 'return': result,
120
+ }
121
+
@@ -0,0 +1,176 @@
1
+
2
+ import os, sys
3
+ import cherrypy
4
+ import jinja2
5
+ import json
6
+ from datetime import datetime
7
+
8
+ from kizano import getLogger, getConfig, Config
9
+ log = getLogger('smartmetertx.server', log_format='json')
10
+
11
+ from .utils import getMongoConnection
12
+ from .controller import SmartMeterController
13
+
14
+ DEFAULT_UI_PATH = os.path.join( sys.exec_prefix, 'share', 'smartmetertx' )
15
+
16
+ class MeterServer(SmartMeterController):
17
+ mongo = None
18
+
19
+ def __init__(self, config: Config):
20
+ super(MeterServer, self)
21
+ self.config = config
22
+ self.db = getMongoConnection(config).get_database(config['mongo'].get('dbname', 'smartmetertx'))
23
+
24
+ def __del__(self):
25
+ self.close()
26
+
27
+ def close(self):
28
+ if self.mongo:
29
+ self.mongo.close()
30
+ self.mongo = None
31
+
32
+ @cherrypy.expose
33
+ def index(self):
34
+ '''
35
+ Home page!
36
+ '''
37
+ return self.returnValue(True, {'hello': 'world'})
38
+
39
+ @cherrypy.expose
40
+ @cherrypy.tools.json_out()
41
+ def meterRead(self, date: str = None):
42
+ '''
43
+ Return a meter read for a specified date.
44
+ Gets the full meter read from the DB.
45
+ '''
46
+ result = {}
47
+ if date is None:
48
+ return self.returnValue(False, 'No date specified.')
49
+ try:
50
+ import dateparser
51
+ queryDate = dateparser.parse(date)
52
+ timerange = {
53
+ '$gte': queryDate.replace( hour=max(0, queryDate.hour-1) ),
54
+ '$lt': queryDate.replace( hour=min(23, queryDate.hour+1) )
55
+ }
56
+ result = self.db.meterReads.find_one({'datetime': timerange })
57
+ if result is None:
58
+ return self.returnValue(False, f'No meter read found for {date}')
59
+ log.debug(result)
60
+ del result['_id']
61
+ result['datetime'] = result['datetime'].strftime('%F/%R:%S')
62
+ return self.returnValue(True, result)
63
+ except Exception as e:
64
+ import traceback as tb
65
+ log.error(f'Error getting meter read for {date}: {e}')
66
+ log.error(tb.format_exc())
67
+ return self.returnValue(False, 'uhm, well, this is embarassing :S')
68
+
69
+ @cherrypy.expose
70
+ @cherrypy.tools.json_out()
71
+ def meterReads(self, fdate: str = None, tdate: str = None):
72
+ '''
73
+ Return a list of meter reads for a specified date range.
74
+ Gets only the list of values paired with the date as an object/key-value pairing.
75
+ '''
76
+ result = []
77
+ if fdate is None:
78
+ return self.returnValue(False, 'No From Date Specified. Need `fdate`.')
79
+ if tdate is None:
80
+ return self.returnValue(False, 'No To Date Specified. Need `tdate`.')
81
+ import dateparser
82
+ fromDate = dateparser.parse(fdate)
83
+ toDate = dateparser.parse(tdate)
84
+ timerange = {
85
+ '$gte': fromDate,
86
+ '$lt': toDate
87
+ }
88
+ projection = { '_id': False, 'reading': True, 'datetime': True}
89
+ reads = list( self.db.meterReads.find({'datetime': timerange }, projection) )
90
+ for mRead in reads:
91
+ sdate = mRead['datetime'].strftime('%F')
92
+ result.append( [sdate, mRead['reading'] ] )
93
+ return self.returnValue(True, result)
94
+
95
+ @cherrypy.expose
96
+ def shutdown(self):
97
+ '''
98
+ Shutdown the server.
99
+ '''
100
+ log.info('Server shutting down...')
101
+ cherrypy.engine.exit()
102
+ log.info('CherryPy Server exit.')
103
+
104
+ class GoogleGraphsFS(SmartMeterController):
105
+ def __init__(self, uiPath: str = None):
106
+ log.info(f'Serving files from {uiPath}')
107
+ fsloader = jinja2.FileSystemLoader( uiPath )
108
+ self.view = jinja2.Environment(loader=fsloader)
109
+ cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
110
+ cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
111
+
112
+ @cherrypy.expose
113
+ def index(self, **kwargs):
114
+ return self.view.get_template('index.html').render(
115
+ page='<p>Index Page.</p>',
116
+ navigation='<li><a href="/user/login">Login</a></li>'
117
+ )
118
+
119
+ def main():
120
+ '''
121
+ Main application/API entry point.
122
+ '''
123
+ cherrypy._cplogging.LogManager.time = lambda self: datetime.now().strftime('%F %T')
124
+ # Default access_log_format '{h} {l} {u} {t} "{r}" {s} {b} "{f}" "{a}"'
125
+ # h - remote.ip, l - "-", u - login (or "-"), t - time, r - request line, s - status, b - content length
126
+ # f - referer, a - User Agent, o - Host or -, i - request.unique_id, z - UtcTime
127
+ cherrypy._cplogging.LogManager.access_log_format = '{' + json.dumps({
128
+ 'time': '{t}',
129
+ 'from': '{h}',
130
+ 'user': '{u}',
131
+ 'host': '{o}',
132
+ 'status': '{s}',
133
+ 'bytes': '{b}',
134
+ 'referer': '{f}',
135
+ 'agent': '{a}'
136
+ }) + '}'
137
+ #'{t} from={h} user={u} host={o} status={s} bytes={b} referer="{f}" agent="{a}"'
138
+ config = getConfig()
139
+ ui_path = os.path.realpath( config.get('server', {}).get('ui.path', DEFAULT_UI_PATH) )
140
+ if not os.path.exists(ui_path):
141
+ possible_paths = [
142
+ os.path.join(os.getenv('HOME', '/home/markizano'), '.local', 'share', 'smartmetertx'),
143
+ os.path.join(sys.prefix, 'local', 'share', 'smartmetertx'),
144
+ os.path.join(sys.prefix, 'share', 'smartmetertx'),
145
+ ]
146
+ for ppath in possible_paths:
147
+ if os.path.exists(ppath):
148
+ ui_path = ppath
149
+ break
150
+ else:
151
+ log.debug(f'Could not find UI in path: {ppath}')
152
+ if not os.path.exists(ui_path):
153
+ log.error(f'Could not find UI path: {ui_path}')
154
+ return 1
155
+ apiConfig = {
156
+ 'tools.trailing_slash.on': False,
157
+ 'tools.json_in.on': True,
158
+ 'tools.staticdir.on': False,
159
+ }
160
+ serverConfig = {
161
+ 'tools.trailing_slash.on': False,
162
+ 'tools.staticdir.on': True,
163
+ 'tools.staticdir.dir': ui_path
164
+ }
165
+ log.info('Starting SmartMeterTX Server...')
166
+ log.debug(config)
167
+ smtx = MeterServer(config)
168
+ content = GoogleGraphsFS( serverConfig['tools.staticdir.dir'] )
169
+ log.debug(f'Got config: {config}')
170
+ cherrypy.config.update(config.get('daemon', {}).get('cherrypy', {}))
171
+ cherrypy.tree.mount(smtx, '/api', { '/': apiConfig } )
172
+ cherrypy.tree.mount(content, '/', {'/': serverConfig })
173
+ cherrypy.engine.start()
174
+ cherrypy.engine.block()
175
+ return 0
176
+
@@ -0,0 +1,110 @@
1
+ '''
2
+ \x1b[31mModule-Level Documentation!\x1b[0m
3
+ '''
4
+ import os
5
+ import dateparser
6
+ import pymongo
7
+ import gnupg
8
+ import json
9
+ from datetime import datetime
10
+
11
+ from kizano import getConfig, getLogger
12
+ from smartmetertx.api import MeterReader
13
+ from smartmetertx.utils import getMongoConnection
14
+
15
+ log = getLogger(__name__)
16
+ HOME = os.getenv('HOME', '')
17
+ SMTX_FROM = dateparser.parse(os.environ.get('SMTX_FROM', 'day before yesterday'))
18
+ SMTX_TO = dateparser.parse(os.environ.get('SMTX_TO', 'today'))
19
+
20
+ class Smtx2Mongo(object):
21
+ '''
22
+ SmartMeterTexas -> MongoDB
23
+ Model object to take the records we get from https://smartmetertexas.com and insert them into
24
+ a mongodb we control for data preservation and other analytics on our electric usage data we
25
+ would want to undertake.
26
+ '''
27
+
28
+ def __init__(self):
29
+ self.config = getConfig()
30
+ self.mongo = getMongoConnection(self.config)
31
+ self.db = self.mongo.get_database(self.config['mongo'].get('dbname', 'smartmetertx'))
32
+ self.getSMTX()
33
+ self.ensureIndexes()
34
+
35
+ def close(self):
36
+ if self.mongo:
37
+ self.mongo.close()
38
+ self.mongo = None
39
+
40
+ def ensureIndexes(self):
41
+ self.db.meterReads.create_index(
42
+ [('reading', 1)],
43
+ background=True
44
+ )
45
+ self.db.meterReads.create_index(
46
+ [('datetime', 1)],
47
+ background=True,
48
+ unique=True
49
+ )
50
+ return self
51
+
52
+ def getSMTX(self):
53
+ log.info('Connecting to SmartMeterTX...')
54
+ gpg = gnupg.GPG(gnupghome=os.path.join(os.getenv('HOME', '/home/markizano') , '.gnupg'), use_agent=True)
55
+ self.smtx = MeterReader()
56
+ smtx_user = self.config['smartmetertx']['user']
57
+ smtx_pass = gpg.decrypt(self.config['smartmetertx']['pass']).data.decode('utf-8').strip()
58
+ self.smtx.login(smtx_user, smtx_pass)
59
+ log.info('Success! Getting meter reads...')
60
+ return self.smtx
61
+
62
+ def getDailyReads(self):
63
+ # Get the meter reads and print the date in the format their API expects.
64
+ reads = self.smtx.get_daily_read(self.config['smartmetertx']['esiid'], SMTX_FROM.strftime('%m/%d/%Y'), SMTX_TO.strftime('%m/%d/%Y'))
65
+ if not reads:
66
+ log.warn('Failed to get records from meterReads()')
67
+ else:
68
+ log.info('Acquired %d meter reads! Inserting into DB...' % len(reads['dailyData']))
69
+ return reads
70
+
71
+ def filterDailyReads(self, dailyData):
72
+ results = []
73
+ log.debug(json.dumps(dailyData, indent=2))
74
+ for meterRead in dailyData:
75
+ meterRead['datetime'] = datetime.strptime('%(date)s %(starttime)s' % meterRead, '%m/%d/%Y %H:%M%p')
76
+ del meterRead['date'], meterRead['starttime']
77
+ meterRead['startreading'] = float(meterRead['startreading'])
78
+ meterRead['endreading'] = float(meterRead['endreading'])
79
+ results.append(meterRead)
80
+ return results
81
+
82
+ def insertDailyData(self, dailyData):
83
+ results = []
84
+ log.info('Inserting %d reads into the DB.' % len(dailyData))
85
+ try:
86
+ insertResult = self.db.meterReads.insert_many(dailyData)
87
+ log.debug(insertResult)
88
+ results.append(insertResult)
89
+ except pymongo.errors.BulkWriteError as e:
90
+ errs = list(filter( lambda x: x['code'] != 11000, e.details['writeErrors'] ))
91
+ if errs:
92
+ raise errs
93
+ log.info('Complete!')
94
+
95
+ def main():
96
+ log.info('Gathering records from %s to %s' % ( SMTX_FROM.strftime('%F/%R'), SMTX_TO.strftime('%F/%R') ) )
97
+ smtx2mongo = Smtx2Mongo()
98
+ reads = smtx2mongo.getDailyReads()
99
+ if not reads:
100
+ log.error('Failed to read smartmetertexas API...')
101
+ return 2
102
+
103
+ dailyData = smtx2mongo.filterDailyReads(reads['dailyData'])
104
+ if dailyData:
105
+ smtx2mongo.insertDailyData(dailyData)
106
+ else:
107
+ log.warning('No records inserted!')
108
+ smtx2mongo.close()
109
+ return 0
110
+
@@ -0,0 +1,22 @@
1
+
2
+ import os
3
+ import pymongo
4
+ import gnupg
5
+ import kizano
6
+ from urllib.parse import urlencode
7
+
8
+ log = kizano.getLogger(__name__)
9
+
10
+ def getMongoConnection(config: kizano.Config):
11
+ # Establish connections to various sources and targets.
12
+ log.info('Connecting to MongoDB...')
13
+ gpg = gnupg.GPG(gnupghome=os.path.join(os.environ['HOME'], '.gnupg'), use_agent=True)
14
+ mongo_user = config['mongo']['username']
15
+ mongo_pass = gpg.decrypt(config['mongo']['password']).data.decode('utf-8')
16
+ mongo_host = config['mongo']['host']
17
+ mongo_opts = urlencode(config['mongo']['opts'])
18
+ mongo_url = f'mongodb://{mongo_user}:{mongo_pass}@{mongo_host}/?{mongo_opts}'
19
+ db = pymongo.MongoClient(mongo_url)
20
+ log.info('Connected!')
21
+ return db
22
+
@@ -0,0 +1,49 @@
1
+
2
+ /*
3
+ If a designer wants to come make this look more pretty, you're more than welcome!
4
+ Feel free to submit a PR!
5
+ */
6
+
7
+ #mychart {
8
+ max-width: 1280px;
9
+ max-height: 800px;
10
+ margin: 0 auto;
11
+ min-height: 400px;
12
+ }
13
+
14
+ #mytable {
15
+ max-width: 1280px;
16
+ max-height: 800px;
17
+ margin: 0 auto;
18
+ border: 2px solid black;
19
+ }
20
+
21
+ #mytable thead {
22
+ font-size: 1.7rem;
23
+ font-weight: bold;
24
+ background-color: #f2f2f2;
25
+ padding: 0.5rem;
26
+ }
27
+
28
+ #mytable th {
29
+ padding: 0.5rem;
30
+ }
31
+
32
+ #mytable td {
33
+ font-size: 1.2rem;
34
+ padding: 0.3rem;
35
+ border-bottom: 1px solid black;
36
+ margin: 0;
37
+ }
38
+
39
+ .kwh input {
40
+ width: 75px;
41
+ height: 24px;
42
+ font-size: 20px;
43
+ }
44
+ .kwh::before {
45
+ content: '$';
46
+ }
47
+ .kwh::after {
48
+ content: '/kWh';
49
+ }
@@ -0,0 +1,37 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>SMT Mongo Metric Reader</title>
5
+ <link rel="stylesheet" type="text/css" href="index.css" />
6
+ <!-- jQuery Core -->
7
+ <script type="text/javascript" src="https://code.jquery.com/jquery-1.11.3.js"></script>
8
+
9
+ <!-- jQueryUI Dependency -->
10
+ <script type="text/javascript" src="https://code.jquery.com/ui/1.11.3/jquery-ui.js"></script>
11
+ <link rel="stylesheet" href="//code.jquery.com/ui/1.13.1/themes/base/jquery-ui.css">
12
+
13
+ <!-- Google Charts -->
14
+ <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
15
+
16
+ <!-- Main Entrypoint to Script for this page. -->
17
+ <script type="text/javascript" src="index.js"></script>
18
+ </head>
19
+ <body>
20
+ <div id="page">
21
+ <header>
22
+ <h2>SmartMeter Texas Meter Reader</h2>
23
+ <p>
24
+ This chart shows data collected from the SmartMeter reader stored in the configured mongodb.
25
+ This software allows the overlay of additional data to draw correlations from other related
26
+ data that can be overlayed on top of this line chart.
27
+ </p>
28
+ </header>
29
+ <controls>
30
+ From Date: <input type="text" id="fdate" />&nbsp;&nbsp;
31
+ To Date: <input type="text" id="tdate" /><br />
32
+ </controls>
33
+ <div id="mychart"></div>
34
+ <table id="mytable"></table>
35
+ </div>
36
+ </body>
37
+ </html>
@@ -0,0 +1,151 @@
1
+
2
+ let MeterRender = {};
3
+ (function(MeterRender){
4
+
5
+ /**
6
+ * Event handler for refreshing the table with the new kWh rate based on what's in the textbox.
7
+ * @param {Event} event The event that triggered the request.
8
+ * @returns {void}
9
+ */
10
+ MeterRender.onKwhChange = function onKwhChange(event) {
11
+ let kwh = $('#kwh').val();
12
+ let tbody = $('#mytable tbody');
13
+ let rows = tbody.find('tr');
14
+ rows.each((i, r) => {
15
+ let row = $(r);
16
+ let consumption = parseFloat(row.find('td').eq(2).text());
17
+ row.find('td').eq(3).text('$' + (consumption * kwh).toFixed(2));
18
+ });
19
+ };
20
+
21
+ /**
22
+ * Draw a table of the monthly aggregate of consumption from the meter reads.
23
+ * The meter reads will come with a day and a meter read. This function will
24
+ * group them by month and show the consumption in a monthly listing.
25
+ * @param {Object} meterReads Meter read data from server.
26
+ * @returns {void}
27
+ */
28
+ MeterRender.drawTable = function drawTable(meterReads) {
29
+ var table = $('#mytable');
30
+ table.empty();
31
+ let months = meterReads.value.reduce((m, x) => {
32
+ // get the month as 2 digits always
33
+ let d = ((d) => `${d.getFullYear()}-${('0'+d.getMonth()).slice(-2)}`)(new Date(x[0]));
34
+ console.log(d);
35
+ if ( typeof m[d] === "undefined" ) m[d] = 0;
36
+ if ( typeof x[1] !== "number")
37
+ console.log("!!!" + x[1]);
38
+ m[d] += x[1];
39
+ return m;
40
+ }, {});
41
+ let monthKeys = Object.keys(months);
42
+ monthKeys.sort();
43
+ var thead = $('<thead>');
44
+ var tr = $('<tr><th>Year</th><th>Month</th><th>Consumption</th></tr>')
45
+ var kwh = $('<input>')
46
+ .attr('type', 'number')
47
+ .attr('id', 'kwh')
48
+ .attr('name', 'kwh')
49
+ .attr('placeholder', 'kWh')
50
+ .attr('step', 0.01)
51
+ .val(0.13)
52
+ .on('change', MeterRender.onKwhChange);
53
+ var th = $('<th>').addClass('kwh').append(kwh);
54
+ tr.append(th);
55
+ thead.append(tr);
56
+ var tbody = $('<tbody>');
57
+ monthKeys.forEach((k) => {
58
+ let date = k.split('-');
59
+ let row = $('<tr>');
60
+ row.append($(`<td></td>`).text(date[0]));
61
+ row.append($(`<td></td>`).text(parseInt(date[1]) + 1));
62
+ row.append($(`<td></td>`).text(months[k].toFixed(2) + ' kWh'));
63
+ row.append($(`<td></td>`).text('$' + (months[k] * kwh.val()).toFixed(2)));
64
+ tbody.append(row);
65
+ });
66
+
67
+ table.append(thead);
68
+ table.append(tbody);
69
+ console.log('Done populating table of meter reads.')
70
+ console.log(meterReads);
71
+ }
72
+
73
+
74
+ /**
75
+ * Google handler for drawing the chart.
76
+ * @param {Object} meterReads Meter read data from server.
77
+ * @returns {void}
78
+ */
79
+ MeterRender.drawChart = function drawChart(meterReads) {
80
+ var data = new google.visualization.DataTable();
81
+ data.addColumn('number', 'Date');
82
+ data.addColumn('number', 'Reading');
83
+ let reads = [...meterReads.value];
84
+ reads.unshift(['Date', 'Reading']);
85
+ var data = google.visualization.arrayToDataTable(reads);
86
+ var options = {
87
+ title: 'SmartMeter Texas Meter Reads',
88
+ legend: { position: 'bottom' }
89
+ };
90
+ var chart = new google.visualization.LineChart(document.getElementById('mychart'));
91
+ chart.draw(data, options);
92
+ };
93
+
94
+ /**
95
+ * Request the meter reads from the server.
96
+ * Sample response from the server looks like:
97
+ * {
98
+ * error: false,
99
+ * status: 200,
100
+ * value: [
101
+ * [
102
+ * "2022-01-01",
103
+ * 00.000
104
+ * ]
105
+ * }
106
+ * }
107
+ *
108
+ * @param {Event} event The event that triggered the request.
109
+ * @returns {void}
110
+ */
111
+ MeterRender.requestMetrics = function requestMetrics(event) {
112
+ let fdate = encodeURI( $('#fdate').val() ), tdate = encodeURI( $('#tdate').val() );
113
+ $.ajax({
114
+ url: `/api/meterReads?fdate=${fdate}&tdate=${tdate}`,
115
+ }).done(meterReads => {
116
+ MeterRender.drawChart(meterReads);
117
+ MeterRender.drawTable(meterReads);
118
+ });
119
+ };
120
+
121
+ /**
122
+ * On load event handler.
123
+ * @param {Event} event The event that triggered the request.
124
+ * @returns {void}
125
+ */
126
+ MeterRender.onLoad = function onLoad(event) {
127
+ let fdate = $('#fdate'), tdate = $('#tdate');
128
+ let sixmonths = new Date( Date.now() - (86400 * 1000 * 365) );
129
+ let yday = new Date(Date.now() - 86400000);
130
+ let dtopts = {
131
+ changeMonth: true,
132
+ changeYear: true,
133
+ dateFormat: "yy-mm-dd"
134
+ };
135
+ fdate.datepicker(dtopts);
136
+ tdate.datepicker(dtopts);
137
+
138
+ fdate.val(`${sixmonths.getFullYear()}-${sixmonths.getMonth()+1}-${sixmonths.getDate()}`);
139
+ tdate.val(`${yday.getFullYear()}-${yday.getMonth()+1}-${yday.getDate()}`);
140
+
141
+ fdate.change(MeterRender.requestMetrics)
142
+ tdate.change(MeterRender.requestMetrics)
143
+ };
144
+
145
+ google.charts.load('current', {'packages': ['corechart']});
146
+ google.charts.setOnLoadCallback(MeterRender.requestMetrics);
147
+ return MeterRender;
148
+ })(MeterRender);
149
+ window.MeterRenderer = MeterRender;
150
+
151
+ $(document).ready(MeterRender.onLoad);
@@ -0,0 +1,24 @@
1
+ [options]
2
+ include_package_data = True
3
+
4
+ [tool:pytest]
5
+ testpaths = tests
6
+ python_files = tests/runtests.py
7
+
8
+ [nosetests]
9
+ detailed-errors = 1
10
+ with-coverage = 1
11
+ cover-package = smartmetertx
12
+ cover-inclusive = 1
13
+ cover-html = 1
14
+ cover-html-dir = tests/coverage/
15
+ debug = nose.loader
16
+ pdb = 0
17
+ pdb-failures = 0
18
+ nocapture = 1
19
+ tests = tests/runtests.py
20
+
21
+ [egg_info]
22
+ tag_build =
23
+ tag_date = 0
24
+
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os, io
4
+ import sys
5
+ from glob import glob
6
+ from pprint import pprint
7
+ from setuptools import setup
8
+
9
+ try:
10
+ import yaml
11
+ except ImportError:
12
+ os.system('pip3 install PyYAML')
13
+ import yaml
14
+
15
+ sys.path.insert(0, os.path.abspath('.'))
16
+
17
+ try:
18
+ PATCH = io.open('.build-id').read().strip()
19
+ except:
20
+ try:
21
+ pkginfo = yaml.safe_load(io.open('PKG-INFO').read())
22
+ PATCH = pkginfo['Version'].split('.').pop()
23
+ except Exception as e:
24
+ PATCH = '0'
25
+
26
+ setup_opts = {
27
+ 'name' : 'smartmetertx2mongo',
28
+ # We change this default each time we tag a release.
29
+ 'version' : f'1.2.{PATCH}',
30
+ 'description' : 'Implementation of smartmetertx to save records to mongodb with config driven via YAML.',
31
+ 'author' : 'Markizano Draconus',
32
+ 'author_email' : 'markizano@markizano.net',
33
+ 'url' : 'https://markizano.net/',
34
+ 'license' : 'GNU',
35
+ 'tests_require' : ['nose', 'mock', 'coverage'],
36
+ 'install_requires' : [
37
+ 'dateparser',
38
+ 'kizano',
39
+ 'pymongo',
40
+ 'requests',
41
+ 'cherrypy',
42
+ 'PyYAML',
43
+ 'python-gnupg',
44
+ 'jinja2',
45
+ ],
46
+ 'package_dir' : { 'smartmetertx': 'lib/smartmetertx' },
47
+ 'packages' : [
48
+ 'smartmetertx',
49
+ ],
50
+ 'scripts' : glob('bin/*'),
51
+ 'test_suite' : 'tests',
52
+ 'data_files' : [
53
+ ('share/smartmetertx', glob('lib/ui/*')),
54
+ ]
55
+ }
56
+
57
+ try:
58
+ import argparse
59
+ HAS_ARGPARSE = True
60
+ except:
61
+ HAS_ARGPARSE = False
62
+
63
+ if not HAS_ARGPARSE: setup_opts['install_requires'].append('argparse')
64
+
65
+ # I botch this too many times.
66
+ if sys.argv[1] == 'test':
67
+ sys.argv[1] = 'nosetests'
68
+
69
+ if 'DEBUG' in os.environ: pprint(setup_opts)
70
+
71
+ setup(**setup_opts)
72
+
73
+ if 'sdist' in sys.argv:
74
+ import gnupg, hashlib
75
+ gpg = gnupg.GPG()
76
+ for artifact in glob('dist/*.tar.gz'):
77
+ # Detach sign the artifact in dist/ folder.
78
+ fd = open(artifact, 'rb')
79
+ checksums = open('dist/CHECKSUMS.txt', 'w+b')
80
+ status = gpg.sign_file(fd, detach=True, output=f'{artifact}.asc')
81
+ print(f'Signed {artifact} with {status.fingerprint}')
82
+
83
+ # create a MD5, SHA1 and SHA256 hash of the artifact.
84
+ for hashname in ['md5', 'sha1', 'sha256']:
85
+ hasher = getattr(hashlib, hashname)()
86
+ fd.seek(0,0)
87
+ hasher.update(fd.read())
88
+ digest = hasher.hexdigest()
89
+ checksums.write(f'''{hashname.upper()}:
90
+ {digest} {artifact}
91
+
92
+ '''.encode('utf-8'))
93
+ print(f'Got {artifact}.{hashname} as {digest}')
94
+ checksums.seek(0, 0)
95
+ chk_status = gpg.sign_file(checksums, detach=True, output=f'dist/CHECKSUMS.txt.asc')
96
+ checksums.close()
97
+ fd.close()
98
+ print(f'Signed CHECKSUMS.txt with {chk_status.fingerprint}')
99
+
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.1
2
+ Name: smartmetertx2mongo
3
+ Version: 1.2.0
4
+ Summary: Implementation of smartmetertx to save records to mongodb with config driven via YAML.
5
+ Home-page: https://markizano.net/
6
+ Author: Markizano Draconus
7
+ Author-email: markizano@markizano.net
8
+ License: GNU
@@ -0,0 +1,19 @@
1
+ README.md
2
+ setup.cfg
3
+ setup.py
4
+ bin/fetchMeterReads.cron.py
5
+ bin/smtx-server.py
6
+ lib/smartmetertx/__init__.py
7
+ lib/smartmetertx/api.py
8
+ lib/smartmetertx/controller.py
9
+ lib/smartmetertx/server.py
10
+ lib/smartmetertx/smtx2mongo.py
11
+ lib/smartmetertx/utils.py
12
+ lib/ui/index.css
13
+ lib/ui/index.html
14
+ lib/ui/index.js
15
+ smartmetertx2mongo.egg-info/PKG-INFO
16
+ smartmetertx2mongo.egg-info/SOURCES.txt
17
+ smartmetertx2mongo.egg-info/dependency_links.txt
18
+ smartmetertx2mongo.egg-info/requires.txt
19
+ smartmetertx2mongo.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ PyYAML
2
+ cherrypy
3
+ dateparser
4
+ jinja2
5
+ kizano
6
+ pymongo
7
+ python-gnupg
8
+ requests