generalize convert_to_c3: sensors and weather have different fields
[chrisu/seepark.git] / web / seepark_web.py
1 from random import uniform
2 import datetime
3 import time
4 import configparser
5 import os
6 import sys
7 from collections import defaultdict
8 from flask import Flask, render_template, jsonify, request, abort, Response
9 import flask.json
10 from flask_sqlalchemy import SQLAlchemy, inspect
11
12
13 app_path = os.path.dirname(os.path.realpath(__file__))
14 lib_path = os.path.join(app_path, '..')
15 sys.path.append(lib_path)
16 from seeparklib.openweathermap import openweathermap_json, OpenWeatherMapError
17
18
19 # https://stackoverflow.com/a/37350445
20 def sqlalchemy_model_to_dict(model):
21     return {c.key: getattr(model, c.key)
22         for c in inspect(model).mapper.column_attrs}
23
24
25 class JSONEncoder(flask.json.JSONEncoder):
26     def default(self, object):
27         if isinstance(object, datetime.datetime):
28             return object.isoformat()
29         elif isinstance(object, db.Model):
30             return sqlalchemy_model_to_dict(object)
31         return super().default(object)
32
33
34 def parse_datetime(date_str):
35     return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
36
37
38 def get_sqlalchemy_database_uri(config):
39     user = config.get('database', 'user')
40     pwd = config.get('database', 'password')
41     host = config.get('database', 'hostname')
42     db = config.get('database', 'database')
43     return 'mysql+mysqldb://{}:{}@{}/{}'.format(user, pwd, host, db)
44
45
46 config = configparser.ConfigParser()
47 config.read(os.environ['SEEPARKINI'])
48 apikey = config.get('openweathermap', 'apikey')
49 cityid = config.get('openweathermap', 'cityid')
50 mainsensor = config.get('temperature', 'mainsensor')
51
52 app = Flask(__name__)
53 app.json_encoder = JSONEncoder
54 app.config['SQLALCHEMY_DATABASE_URI'] = get_sqlalchemy_database_uri(config)
55 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
56 db = SQLAlchemy(app)
57 db.reflect(app=app)
58
59
60 class Sensors(db.Model):
61     __tablename__ = 'sensors'
62
63
64 class OpenWeatherMap(db.Model):
65     __tablename__ = 'openweathermap'
66
67
68 def select_sensordata(sensor_id, sensor_type, begin, end, mode):
69     query = Sensors.query
70     if sensor_id is not None:
71         query = query.filter(Sensors.sensor_id == sensor_id)
72     if sensor_type is not None:
73         query = query.filter(Sensors.value_type == sensor_type)
74     if begin is not None:
75         query = query.filter(Sensors.timestamp >= begin)
76     if end is not None:
77         query = query.filter(Sensors.timestamp <= end)
78     if mode == 'consolidated' and begin is not None and end is not None:
79         # copied from munin/master/_bin/munin-cgi-graph.in
80         # interval in seconds for data points
81         resolutions = dict(
82             day   =   300,
83             week  =  1800,
84             month =  7200,
85             year  = 86400,
86         )
87         duration = (end - begin).total_seconds()
88         day = 60 * 60 * 24
89         if duration < day:
90             resolution = resolutions['day']
91         elif duration < 7 * day:
92             resolution = resolutions['week']
93         elif duration < 31 * day:
94             resolution = resolutions['month']
95         else:
96             resolution = resolutions['year']
97         # TODO: filter out samples from 'result'
98         # something like 
99         # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
100     return query.all()
101
102
103 def select_openweatherdata(cityid, begin, end, mode):
104     query = OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
105     if begin is not None:
106         query = query.filter(OpenWeatherMap.datetime >= begin)
107     if end is not None:
108         query = query.filter(OpenWeatherMap.datetime <= end)
109     if mode == 'consolidated' and begin is not None and end is not None:
110         # copied from munin/master/_bin/munin-cgi-graph.in
111         # interval in seconds for data points
112         resolutions = dict(
113             day   =   300,
114             week  =  1800,
115             month =  7200,
116             year  = 86400,
117         )
118         duration = (end - begin).total_seconds()
119         day = 60 * 60 * 24
120         if duration < day:
121             resolution = resolutions['day']
122         elif duration < 7 * day:
123             resolution = resolutions['week']
124         elif duration < 31 * day:
125             resolution = resolutions['month']
126         else:
127             resolution = resolutions['year']
128         # TODO: filter out samples from 'result'
129         # something like 
130         # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
131     return query.all()
132
133
134 def convert_to_c3(result, id, field_x, field_y):
135     c3result = defaultdict(list)
136     for row in result:
137         c3result[getattr(row, id)].append(getattr(row, field_y))
138         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
139         c3result[str(getattr(row, id)) + '_x'].append(dt)
140     return c3result
141
142
143 def request_arg(key, type, default=None):
144     """Returns the key from the request if available, otherwise the default value.
145     In case type is provided and the key is present, the value is converted by calling type.
146     In other words: Reimplement request.args.get but don't return default value if
147     type raises a ValueError."""
148     if key in request.args:
149         try:
150             return type(request.args[key])
151         except ValueError as e:
152             abort(Response(str(e), 400))
153     else:
154         return default
155
156
157 def sensordata(sensor_id=None, sensor_type=None):
158     begin = request_arg('begin', parse_datetime)
159     end = request_arg('end', parse_datetime)
160     mode = request.args.get('mode', 'full')
161     format = request.args.get('format', 'default')
162
163     result = select_sensordata(sensor_id, sensor_type, begin, end, mode)
164
165     if format == 'c3':
166         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
167     return result
168
169
170 def openweathermapdata(cityid):
171     begin = request_arg('begin', parse_datetime)
172     end = request_arg('end', parse_datetime)
173     mode = request.args.get('mode', 'full')
174     format = request.args.get('format', 'default')
175
176     result = select_openweatherdata(cityid, begin, end, mode)
177
178     if format == 'c3':
179         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
180     return result
181
182
183 def currentairtemperature(cityid):
184     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
185     return result.temp, result.datetime
186
187
188 def currentwatertemperature(sensorid):
189     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
190     return result.value, result.timestamp
191
192
193 @app.route('/api/<version>/sensors/')
194 def sensors(version):
195     """List all sensors found in the database"""
196     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
197     return jsonify(result)
198
199
200 @app.route('/api/<version>/sensor/id/<sensor_id>')
201 def sensorid(version, sensor_id):
202     """Return all data for a specific sensor
203
204     URL parameters:
205     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
206     end=<datetime>, optional, format like "2018-05-19T21:07:53"
207     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
208     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
209     """
210     result = sensordata(sensor_id=sensor_id)
211     return jsonify(result)
212
213
214 @app.route('/api/<version>/sensor/type/<sensor_type>')
215 def sensortype(version, sensor_type):
216     """Return all data for a specific sensor type
217
218     URL parameters:
219     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
220     end=<datetime>, optional, format like "2018-05-19T21:07:53"
221     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
222     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
223     """
224     result = sensordata(sensor_type=sensor_type)
225     return jsonify(result)
226
227
228 @app.route('/api/<version>/openweathermap/cities')
229 def openweathermap_cities(version):
230     """List all city IDs found in the database"""
231     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
232     return jsonify(result)
233
234
235 @app.route('/api/<version>/openweathermap/city/<cityid>')
236 def openweathermap_city(version, cityid):
237     """List all data found for a city"""
238     result = openweathermapdata(cityid=cityid)
239     return jsonify(result)
240
241
242 @app.route('/data/', defaults={'timespan': 1})
243 @app.route("/data/<int:timespan>", methods=['GET'])
244 def data(timespan):
245     granularity = 5 * timespan               # (every) minute(s) per day
246     samples = 60/granularity * 24 * timespan # per hour over whole timespan
247     s4m   = []
248     s4m_x = []
249     s5m   = []
250     s5m_x = []
251     end   = time.time()
252     start = end - samples * granularity * 60
253
254     for i in range(int(samples)):
255         s4m.append(uniform(-10,30))
256         s5m.append(uniform(-10,30))
257         s4mt = uniform(start, end)
258         s5mt = uniform(start, end)
259         s4m_x.append(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(s4mt)))
260         s5m_x.append(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(s5mt)))
261
262     data = {
263         '0316a2193bff':   s4m,
264         '0316a2193bff_x': s4m_x,
265         '0316a21383ff':   s5m,
266         '0316a21383ff_x': s5m_x,
267         }
268
269     return jsonify(data)
270
271
272 @app.route("/")
273 def index():
274     airvalue, airtime     = currentairtemperature(cityid)
275     watervalue, watertime = currentwatertemperature(mainsensor)
276
277     return render_template(
278         'seepark_web.html',
279         apikey=apikey,
280         watervalue=watervalue,
281         watertime=watertime,
282         airvalue=airvalue,
283         airtime=airtime,
284     )