Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
318 lines (267 loc) · 11.1 KB
/
Copy pathserver.py
File metadata and controls
318 lines (267 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# coding=utf-8
import optparse
import logging
import threading
import time
import glob
import os, sys
from io import StringIO
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, PlainTextResponse
import uvicorn
import pandas as pd
import numpy as np
import co2meter as co2
_DEFAULT_HOST = '127.0.0.1'
_DEFAULT_PORT = '1201'
_DEFAULT_INTERVAL = 20 # seconds
_DEFAULT_NAME = 'co2'
_INIT_TIME = 10 # time to initialize and calibrate device
_COLORS = {'r': '#E81F2E', 'y': '#FAAF4C', 'g': '#7FB03F'}
_IMG_G = '1324881/36358454-d707e2f4-150e-11e8-9bd1-b479e232f28f'
_IMG_Y = '1324881/36358456-d8b513ba-150e-11e8-91eb-ade37733b19e'
_IMG_R = '1324881/36358457-da3e3e8c-150e-11e8-85af-855571275d88'
_RANGE_MID = [800, 1200]
_CO2_MAX_VALUE = 3200 # Cut our yaxis here
_DEGREES_CELSIUS = "℃" # Unicode U+2103, Degree Celisus
_DEGREES_FAHRENHEIT = "℉" # Unicode U+2109, Degree Fahrenheit
_name = _DEFAULT_NAME
_fahrenheit = False
_tight_margins = False
###############################################################################
mon = None
app = FastAPI()
def get_templates_dir(relative_path):
""" Get absolute path to resource, works for dev and for PyInstaller """
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
templates = Jinja2Templates(directory=get_templates_dir("templates"))
#############################################################################
# Dashboard on plotly.js
#############################################################################
def prepare_data(name=None, span='24H'):
data = read_logs(name)
data = pd.read_csv(StringIO(data), parse_dates=[0]).set_index('timestamp')
if span != 'FULL':
data = data.last(span)
global _fahrenheit
if _fahrenheit:
data['temp'] = data['temp'].apply(celsiusToFahrenheit)
if span == '1H':
# int64 has problems with serialisation, translate to floats
data['co2'] = pd.to_numeric(data['co2'], downcast='float')
elif span == '24H':
data = data.resample('60s').mean()
elif span == '7D':
data = data.resample('600s').mean()
elif span == '30D':
data = data.resample('1H').mean()
elif span == 'FULL':
if len(data) > 3000: # Resample only long series
data = data.resample('1H').mean()
data = data.round({'co2': 0, 'temp': 1})
return data
def rect(y0, y1, color):
return {'type': 'rect', 'layer': 'below',
'xref': 'paper', 'x0': 0, 'x1': 1,
'yref': 'y', 'y0': y0, 'y1': y1,
'fillcolor': color, 'opacity': 0.2, 'line': {'width': 0}}
def caption(title, x, y):
return {'xref': 'paper', 'yref': 'paper', 'x': x, 'y': y, 'text': title,
'showarrow': False, 'font': {'size': 16},
'xanchor': 'center', 'yanchor': 'bottom'}
#############################################################################
#@app.get("/chart/")
#@app.get("/chart/{name>}")
@app.get("/chart/{name}/{freq}")
def chart_co2_temp(request: Request, name=None, freq='24H'):
data = prepare_data(name, freq)
defaultTMin = 18
defaultTMax = 25
temperatureData = data['temp']
deg = _DEGREES_CELSIUS
global _fahrenheit
if _fahrenheit:
defaultTMin = 60
defaultTMax = 80
deg = _DEGREES_FAHRENHEIT
if _tight_margins:
co2_min = data['co2'].min() * 0.95
co2_max = data['co2'].max() * 1.05
t_min = data['temp'].min() * 0.95
t_max = data['temp'].max() * 1.05
else:
co2_min = min(500, data['co2'].min() - 50)
co2_max = min(max(2000, data['co2'].max() + 50), _CO2_MAX_VALUE)
t_min = min(defaultTMin, temperatureData.min())
t_max = max(defaultTMax, temperatureData.max())
rect_green = rect(co2_min, _RANGE_MID[0], _COLORS['g'])
rect_yellow = rect(_RANGE_MID[0], _RANGE_MID[1], _COLORS['y'])
rect_red = rect(_RANGE_MID[1], co2_max, _COLORS['r'])
# Check if mobile
try:
agent = request.headers.get('User-Agent')
phones = ['iphone', 'android', 'blackberry', 'fennec', 'iemobile']
staticPlot = any(phone in agent.lower() for phone in phones)
except RuntimeError:
staticPlot = False
# Make figure
index = data.index.format()
co2 = list(np.where(data.co2.isnull(), None, data.co2))
temp = list(np.where(data.temp.isnull(), None, data.temp))
d_co2 = {'mode': 'lines+markers', 'type': 'scatter',
'name': 'CO2 concentration (ppm)',
'xaxis': 'x1', 'yaxis': 'y1',
'x': index, 'y': co2}
d_temp = {'mode': 'lines+markers', 'type': 'scatter',
'name': 'Temperature (°C)',
'xaxis': 'x1', 'yaxis': 'y2',
'x': index, 'y': temp}
config = {'displayModeBar': False, 'staticPlot': staticPlot}
layout = {'margin': {'l': 30, 'r': 10, 'b': 30, 't': 30},
'showlegend': False,
'shapes': [rect_green, rect_yellow, rect_red],
'xaxis1': {'domain': [0, 1], 'anchor': 'y2'},
'yaxis1': {'domain': [0.55, 1], 'anchor': 'free', 'position': 0,
'range': [co2_min, co2_max]},
'yaxis2': {'domain': [0, 0.45], 'anchor': 'x1',
'range': [t_min, t_max]},
'annotations': [caption('CO2 concentration (ppm)', 0.5, 1),
caption(f'Temperature ({deg})', 0.5, 0.45)]
}
fig = {'data': [d_co2, d_temp], 'layout': layout, 'config': config}
return fig
#############################################################################
@app.route("/")
def dashboard_plotly(request: Request):
# Get list of files
files = glob.glob('logs/*.csv')
files = [os.path.splitext(os.path.basename(_))[0] for _ in files]
# And find selected for jinja template
files = [(_, _ == _name) for _ in files]
return templates.TemplateResponse('dashboard.html', {"request": request, "files": files})
#############################################################################
# Monitoring routines
#############################################################################
def read_logs(name=None):
""" read log files """
if name is None:
name = _name
with open(os.path.join('logs', name + '.csv'), 'r') as f:
data = f.read()
return data
#############################################################################
def write_to_log(vals):
""" file name for a current log """
# Create file if does not exist
fname = os.path.join('logs', _name + '.csv')
if not os.path.exists('logs'):
os.makedirs('logs')
if not os.path.isfile(fname):
with open(fname, 'a') as f:
f.write('timestamp,co2,temp\n')
# Append to file
with open(fname, 'a') as f:
f.write('%s,%d,%.1f\n' % vals)
def read_co2_data(bypass_decrypt):
""" A small hack to read co2 data from monitor in order to account for case
when monitor is not initialized yet
"""
global mon
if mon is None:
# Try to initialize
try:
mon = co2.CO2monitor(bypass_decrypt=bypass_decrypt)
# Sleep. If we read from device before it is calibrated, we'll
# get wrong values
time.sleep(_INIT_TIME)
except OSError:
return None
try:
return mon.read_data_raw(max_requests=1000)
except OSError:
# We kill the link and will require to initialize monitor again next time
mon = None
return None
def monitoring_CO2(interval, bypass_decrypt):
""" Tread for monitoring / logging """
while _monitoring:
# Request concentration and temperature
vals = read_co2_data(bypass_decrypt=bypass_decrypt)
if vals is None:
logging.info('[%s] monitor is not connected' % co2.now())
else:
# Write to log and sleep
logging.info('[%s] %d ppm, %.1f deg C' % tuple(vals))
write_to_log(vals)
# Sleep for the next call
time.sleep(interval)
def start_monitor(interval=_DEFAULT_INTERVAL, bypass_decrypt=False):
""" Start CO2 monitoring in a thread """
logging.basicConfig(level=logging.INFO)
global _monitoring
_monitoring = True
t = threading.Thread(target=monitoring_CO2, args=(interval, bypass_decrypt, ), daemon=True)
t.start()
return t
def start_server():
""" Runs FastAPI instance using command line arguments """
parser = optparse.OptionParser()
parser.add_option("-H", "--host",
help="Hostname of the Flask app [default %s]" % _DEFAULT_HOST,
default=_DEFAULT_HOST)
parser.add_option("-P", "--port",
help="Port for the Flask app [default %s]" % _DEFAULT_PORT,
default=_DEFAULT_PORT)
parser.add_option("-I", "--interval",
help="Interval in seconds for CO2meter requests [default %d]" % _DEFAULT_INTERVAL,
default=_DEFAULT_INTERVAL)
parser.add_option("-N", "--name",
help="Name for the log file [default %s]" % _DEFAULT_NAME,
default=_DEFAULT_NAME)
parser.add_option("-m", "--nomonitoring",
help="No live monitoring (only flask server)",
action="store_true", dest="no_monitoring")
parser.add_option("-s", "--noserver",
help="No server (only monitoring to file)",
action="store_true", dest="no_server")
parser.add_option("-d", "--debug",
action="store_true", dest="debug",
help=optparse.SUPPRESS_HELP)
parser.add_option("-F", "--fahrenheit",
help="Show the temperature in Fahrenheit [default False]",
action="store_true",
default=False,
dest="fahrenheit")
parser.add_option("-b", "--bypass-decrypt",
help="Bypass decrypt (needed for certain models of the device)",
action="store_true", dest="bypass_decrypt",
#default=True
)
parser.add_option("-t", "--tight-margins",
help="Use tight margins when plotting",
action="store_true", dest="tight_margins")
options, _ = parser.parse_args()
if options.debug and not options.no_monitoring:
parser.error("--debug option could be used only with --no_monitoring")
global _name
_name = options.name
global _fahrenheit
_fahrenheit = options.fahrenheit
global _tight_margins
_tight_margins = options.tight_margins
# Start monitoring
if not options.no_monitoring:
start_monitor(interval=int(options.interval), bypass_decrypt=bool(options.bypass_decrypt))
# Start server
if not options.no_server:
uvicorn.run(app, host="0.0.0.0", port=1201)
def celsiusToFahrenheit(c):
return (9 * float(c)) / 5 + 32
if __name__ == '__main__':
start_server()