Simplify REST API
This commit is contained in:
parent
c15e3a2e9a
commit
3bbd7430be
9 changed files with 390 additions and 0 deletions
15
rest/README.md
Normal file
15
rest/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# CosmicPi REST API
|
||||||
|
|
||||||
|
## Development
|
||||||
|
Make sure that CosmicPi UI service does not run (`sudo systemctl stop CosmicPi-UI`),
|
||||||
|
navigate to `/frontend` and run:
|
||||||
|
```
|
||||||
|
FLASK_DEBUG=1 FLASK_APP=${PWD}/app.py python -m flask run --host=0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
- GET `/histogram.png?start_time=0&end_time=1521059693&bin_size_seconds=1`
|
||||||
|
- GET `/params`
|
||||||
|
- GET `/wifi`
|
||||||
|
- PUT `/wifi?ssid=CosmicPi&pass=12345678`
|
||||||
|
- GET `/data?format=csv`
|
||||||
0
rest/__init__.py
Normal file
0
rest/__init__.py
Normal file
21
rest/app.py
Normal file
21
rest/app.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from flask import Flask, request
|
||||||
|
from flask_restful import Api
|
||||||
|
from flask_cors import CORS
|
||||||
|
from .wifi import Wifi
|
||||||
|
from .histogram import Histogram
|
||||||
|
from .series import Series
|
||||||
|
from .auth import Auth
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
api = Api(app)
|
||||||
|
|
||||||
|
CORS(app, resources=r'/api/*')
|
||||||
|
|
||||||
|
api.add_resource(Auth, '/api/auth')
|
||||||
|
api.add_resource(Wifi, '/api/wifi')
|
||||||
|
api.add_resource(Histogram, '/api/histogram.png')
|
||||||
|
api.add_resource(Series, '/api/series')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=True)
|
||||||
23
rest/auth.py
Normal file
23
rest/auth.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from flask import request
|
||||||
|
from flask_restful import Resource, abort
|
||||||
|
from .config import Config
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = '_'.join([Config.get('UI', 'username'), Config.get('UI', 'password')])
|
||||||
|
|
||||||
|
|
||||||
|
def requires_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if 'token' not in request.args or request.args['token'] != TOKEN:
|
||||||
|
return abort(401)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
class Auth(Resource):
|
||||||
|
def get(self):
|
||||||
|
if request.args['token'] != TOKEN:
|
||||||
|
abort(401)
|
||||||
|
return {'result': 'success', 'message': 'Username and password are correct'}
|
||||||
5
rest/config.py
Normal file
5
rest/config.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import configparser
|
||||||
|
|
||||||
|
|
||||||
|
Config = configparser.ConfigParser()
|
||||||
|
Config.read('../config/CosmicPi.config')
|
||||||
94
rest/histogram.py
Normal file
94
rest/histogram.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
from flask import request, make_response
|
||||||
|
from flask_restful import Resource
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
import io
|
||||||
|
import sqlite3
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
|
SQLITE_LOCATION = Config.get("Storage", "sqlite_location")
|
||||||
|
|
||||||
|
|
||||||
|
class Histogram(Resource):
|
||||||
|
def get(self):
|
||||||
|
start_time = int(request.args['start_time'])
|
||||||
|
end_time = int(request.args['end_time'])
|
||||||
|
bin_size_seconds = int(request.args['bin_size_seconds'])
|
||||||
|
|
||||||
|
# render the plot
|
||||||
|
img = build_histogram(start_time, end_time, bin_size_seconds)
|
||||||
|
|
||||||
|
# return the plot
|
||||||
|
response = make_response(img)
|
||||||
|
response.headers['Content-Type'] = 'image/png'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def build_histogram(start_time, end_time, bin_size_seconds):
|
||||||
|
plot_title = ''
|
||||||
|
|
||||||
|
# get some data
|
||||||
|
data = []
|
||||||
|
conn = sqlite3.connect(SQLITE_LOCATION, timeout=60.0)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# only get the last n seconds if the start time was negative
|
||||||
|
if start_time < 0:
|
||||||
|
plot_title += "Histogram of events over the last {0:.1f} minutes\nbin size: {1:d} [s]".format(-start_time / 60.,
|
||||||
|
bin_size_seconds)
|
||||||
|
cursor.execute("SELECT * FROM Events ORDER BY UTCUnixTime DESC, SubSeconds DESC;")
|
||||||
|
start_time = cursor.fetchone()[0] + start_time
|
||||||
|
end_time = 9000000000
|
||||||
|
else:
|
||||||
|
plot_title += "Histogram of events over a set time\nbin size: " + str(bin_size_seconds) + " [s]"
|
||||||
|
cursor.execute("SELECT * FROM Events WHERE UTCUnixTime BETWEEN ? AND ? ORDER BY UTCUnixTime DESC, SubSeconds DESC;",
|
||||||
|
(start_time, end_time))
|
||||||
|
data = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# massage data
|
||||||
|
if len(data) == 0:
|
||||||
|
plt.hist([])
|
||||||
|
plt.title("No data to display")
|
||||||
|
else:
|
||||||
|
event_time_list = [data[i][0] + data[i][1] for i in range(len(data))]
|
||||||
|
# event_time_list = [data[i][0] for i in range(len(data))]
|
||||||
|
bin_edges = range(int(event_time_list[len(event_time_list) - 1]), int(event_time_list[0]), bin_size_seconds)
|
||||||
|
x_axis_limits = (start_time, int(event_time_list[0]) + 1)
|
||||||
|
# convert our unix timestamps to Matplotlib format
|
||||||
|
event_time_list = mdates.epoch2num(event_time_list)
|
||||||
|
bin_edges = mdates.epoch2num(bin_edges)
|
||||||
|
x_axis_limits = mdates.epoch2num(x_axis_limits)
|
||||||
|
|
||||||
|
# make the plot
|
||||||
|
plt.hist(event_time_list, bins=bin_edges)
|
||||||
|
plt.title(plot_title)
|
||||||
|
plt.xlabel("Time [UTC]")
|
||||||
|
plt.ylabel("Number of Events per {0:d} seconds [1]".format(bin_size_seconds))
|
||||||
|
|
||||||
|
plt.subplots_adjust(bottom=0.2)
|
||||||
|
plt.xticks(rotation=25)
|
||||||
|
plt.tick_params(which='both', width=2, direction="out", top=False, right=False)
|
||||||
|
plt.tick_params(which='major', length=5)
|
||||||
|
plt.tick_params(which='minor', length=3, color='r')
|
||||||
|
|
||||||
|
# do the date formatting
|
||||||
|
ax = plt.gca()
|
||||||
|
locator = mdates.AutoDateLocator(minticks=7)
|
||||||
|
locator.intervald[mdates.SECONDLY] = [1, 10, 30]
|
||||||
|
formatter = mdates.AutoDateFormatter(locator)
|
||||||
|
formatter.scaled[1 / (24. * 60.)] = '%H:%M:%S'
|
||||||
|
ax.xaxis.set_major_locator(locator)
|
||||||
|
ax.xaxis.set_major_formatter(formatter)
|
||||||
|
ax.set_xlim(x_axis_limits)
|
||||||
|
|
||||||
|
# return the generated plot
|
||||||
|
img = io.BytesIO()
|
||||||
|
plt.savefig(img, format='png')
|
||||||
|
img.seek(0)
|
||||||
|
plt.close()
|
||||||
|
return img.getvalue()
|
||||||
|
|
||||||
2
rest/requirements.txt
Normal file
2
rest/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask_restful
|
||||||
|
flask_cors
|
||||||
60
rest/series.py
Normal file
60
rest/series.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from flask import request, make_response
|
||||||
|
from flask_restful import Resource
|
||||||
|
from .config import Config
|
||||||
|
import sqlite3
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
|
||||||
|
|
||||||
|
SQLITE_LOCATION = Config.get("Storage", "sqlite_location")
|
||||||
|
|
||||||
|
|
||||||
|
class Series(Resource):
|
||||||
|
def get(self):
|
||||||
|
format = request.args['format']
|
||||||
|
limit = request.args.get('limit', 20)
|
||||||
|
since = request.args.get('since', 0)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(SQLITE_LOCATION, timeout=60.0)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get column names
|
||||||
|
cursor.execute("PRAGMA table_info(Events);")
|
||||||
|
col_data = cursor.fetchall()
|
||||||
|
col_names = []
|
||||||
|
for i in range(0, len(col_data)):
|
||||||
|
col_names.append(col_data[i][1])
|
||||||
|
|
||||||
|
# Get data from database
|
||||||
|
cursor.execute("SELECT * FROM Events WHERE UTCUnixTime >= %d ORDER BY UTCUnixTime DESC, SubSeconds DESC LIMIT %d;" % (since, limit))
|
||||||
|
data = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if format == 'json':
|
||||||
|
return Series._get_json(col_names, data)
|
||||||
|
elif format == 'csv':
|
||||||
|
return Series._get_csv(col_names, data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_json(col_names, data):
|
||||||
|
items = []
|
||||||
|
for row in data:
|
||||||
|
item = {}
|
||||||
|
for i in range(len(col_names)):
|
||||||
|
item[col_names[i]] = row[i]
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_csv(col_names, data):
|
||||||
|
"""
|
||||||
|
Write CSV export to memory
|
||||||
|
"""
|
||||||
|
output = io.BytesIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(col_names)
|
||||||
|
writer.writerows(data)
|
||||||
|
|
||||||
|
response = make_response(output.getvalue())
|
||||||
|
response.headers['Content-Type'] = 'text/csv'
|
||||||
|
return response
|
||||||
170
rest/wifi.py
Normal file
170
rest/wifi.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
from flask import request
|
||||||
|
from flask_restful import Resource
|
||||||
|
from .config import Config
|
||||||
|
from .auth import requires_auth
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import thread
|
||||||
|
import urllib2
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_WIFI_NAME = Config.get("Default WiFi", "name")
|
||||||
|
DEFAULT_WIFI_PASS = Config.get("Default WiFi", "password")
|
||||||
|
WPA_SUPPLICANT_LOCATION = Config.get("MISC", "wpa_supplicant_location")
|
||||||
|
|
||||||
|
|
||||||
|
class Wifi(Resource):
|
||||||
|
@requires_auth
|
||||||
|
def get(self):
|
||||||
|
# Get current network
|
||||||
|
connected_network_name_response = ''
|
||||||
|
try:
|
||||||
|
connected_network_name_response = subprocess.check_output(['sudo', 'iwgetid'])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
err_text = 'ERROR get connected network: %s' % str(e)
|
||||||
|
connected_network_name_response = err_text
|
||||||
|
print(err_text)
|
||||||
|
connected_network_name_str = re.findall('\"(.*?)\"', connected_network_name_response)
|
||||||
|
|
||||||
|
if len(connected_network_name_str) < 1:
|
||||||
|
connected_network_name_str = ''
|
||||||
|
else:
|
||||||
|
connected_network_name_str = connected_network_name_str[0]
|
||||||
|
|
||||||
|
# Available networks
|
||||||
|
wifi_network_list = [connected_network_name_str]
|
||||||
|
available_networks_response = ''
|
||||||
|
try:
|
||||||
|
available_networks_response = subprocess.check_output(['sudo', 'iw', 'dev', 'wlan0', 'scan'])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
err_text = 'ERROR get list of networks: %s' % str(e)
|
||||||
|
print(err_text)
|
||||||
|
available_networks_response = err_text
|
||||||
|
available_networks_lines = available_networks_response.split('\n')
|
||||||
|
for availableNetworksLine in available_networks_lines:
|
||||||
|
if 'SSID' in availableNetworksLine:
|
||||||
|
essid = availableNetworksLine.replace('SSID:', '').strip()
|
||||||
|
wifi_network_list.append(essid)
|
||||||
|
wifi_network_list = filter(lambda x: x != '', wifi_network_list)
|
||||||
|
|
||||||
|
# Print everything
|
||||||
|
return {
|
||||||
|
'current': connected_network_name_str,
|
||||||
|
'available': wifi_network_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
@requires_auth
|
||||||
|
def post(self):
|
||||||
|
ssid = request.form['ssid']
|
||||||
|
password = request.form['pass']
|
||||||
|
|
||||||
|
thread.start_new_thread(connect_to_wifi, (ssid, password))
|
||||||
|
|
||||||
|
msg = 'The CosmicPi will now try to connect to the WiFi "{}". ' \
|
||||||
|
"If no internet connection is found or the connection was not " \
|
||||||
|
"successful the CosmiPi will recreate the WiFi hotspot. Please " \
|
||||||
|
"wait at least two minutes.".format(ssid)
|
||||||
|
return {
|
||||||
|
'message': msg
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fall_back_to_ap():
|
||||||
|
# empty the wpa supplicant to it's default
|
||||||
|
wpa_supplicant_string = "country=GB\n" # Todo: Check, that this string in front is still correct!
|
||||||
|
wpa_supplicant_string += "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n"
|
||||||
|
wpa_supplicant_string += "update_config=1\n"
|
||||||
|
wpa_supplicant_string += "\nnetwork={\n"
|
||||||
|
net = (DEFAULT_WIFI_NAME, DEFAULT_WIFI_PASS)
|
||||||
|
wpa_supplicant_string += '\tssid="{}"\n'.format(net[0])
|
||||||
|
# check if we need a password
|
||||||
|
if str(net[1]) == "":
|
||||||
|
wpa_supplicant_string += '\tpsk="{}"\n'.format(net[1])
|
||||||
|
wpa_supplicant_string += "}\n"
|
||||||
|
with open(WPA_SUPPLICANT_LOCATION, 'w') as file:
|
||||||
|
file.write(wpa_supplicant_string)
|
||||||
|
# configure controler to accept the new configuration
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("wpa_cli -i wlan0 reconfigure", shell=True)
|
||||||
|
time.sleep(2)
|
||||||
|
except ImportError:
|
||||||
|
print("This OS is not linux enough to handle a hotspot in this way")
|
||||||
|
|
||||||
|
# restart the hotspot
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
subprocess.call("systemctl start dnsmasq",
|
||||||
|
shell=True) # well, here we actually care if the hotspot starts, but oh well...
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("ifconfig wlan0 down", shell=True)
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("ifconfig wlan0 up", shell=True)
|
||||||
|
except ImportError:
|
||||||
|
print("This OS is not linux enough to handle a hotspot in this way")
|
||||||
|
|
||||||
|
|
||||||
|
def connect_to_wifi(name, pw):
|
||||||
|
# build the string for the WPA supplicant
|
||||||
|
wpa_supplicant_string = "country=GB\n" # Todo: Check, that this string in front is still correct!
|
||||||
|
wpa_supplicant_string += "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n"
|
||||||
|
wpa_supplicant_string += "update_config=1\n"
|
||||||
|
networks = [(name, pw), (DEFAULT_WIFI_NAME, DEFAULT_WIFI_PASS)]
|
||||||
|
for net in networks:
|
||||||
|
wpa_supplicant_string += "\nnetwork={\n"
|
||||||
|
wpa_supplicant_string += '\tssid="{}"\n'.format(net[0])
|
||||||
|
# check if we need a password
|
||||||
|
if not str(net[1]) == "":
|
||||||
|
wpa_supplicant_string += '\tpsk="{}"\n'.format(net[1])
|
||||||
|
wpa_supplicant_string += "}\n"
|
||||||
|
|
||||||
|
# deactivate htospot and turn the WiFi back on
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
subprocess.call("systemctl stop dnsmasq", shell=True) # we very much don't care if this fails
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("ifconfig wlan0 down", shell=True)
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("ifconfig wlan0 up", shell=True)
|
||||||
|
except ImportError:
|
||||||
|
print("This OS is not linux enough to handle a hotspot in this way")
|
||||||
|
# write wpa_supplicant string to the file
|
||||||
|
with open(WPA_SUPPLICANT_LOCATION, 'w') as file:
|
||||||
|
file.write(wpa_supplicant_string)
|
||||||
|
# configure controler to accept the new configuration
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
time.sleep(2)
|
||||||
|
subprocess.call("wpa_cli -i wlan0 reconfigure", shell=True)
|
||||||
|
except ImportError:
|
||||||
|
print("This OS is not linux enough to handle a hotspot in this way")
|
||||||
|
|
||||||
|
# wait for an internet connection (max 2 min)
|
||||||
|
start_time = time.time()
|
||||||
|
have_internet = False
|
||||||
|
while not have_internet and ((start_time + 120) > time.time()):
|
||||||
|
have_internet = internet_on()
|
||||||
|
|
||||||
|
# if we have no internet, restart the hotspot, otherwise we are done for now
|
||||||
|
if have_internet:
|
||||||
|
print("Successfully connected to the internet (yeah)")
|
||||||
|
# wait a bit before we send the mail
|
||||||
|
time.sleep(5)
|
||||||
|
# ToDo: This would be a good point to send a mail or something similar to the user.
|
||||||
|
# Just to inform them where their cosmicPi is and what's it doing
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print("No internet connection here, falling back to hotspot!")
|
||||||
|
fall_back_to_ap()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def internet_on():
|
||||||
|
try:
|
||||||
|
urllib2.urlopen('http://heise.de', timeout=2)
|
||||||
|
return True
|
||||||
|
except urllib2.URLError:
|
||||||
|
return False
|
||||||
Loading…
Reference in a new issue