From 3bbd7430be5b32a079c86aef52927dcb86c5435b Mon Sep 17 00:00:00 2001 From: Darko Lukic Date: Sat, 24 Mar 2018 00:34:41 +0000 Subject: [PATCH] Simplify REST API --- rest/README.md | 15 ++++ rest/__init__.py | 0 rest/app.py | 21 ++++++ rest/auth.py | 23 ++++++ rest/config.py | 5 ++ rest/histogram.py | 94 +++++++++++++++++++++++ rest/requirements.txt | 2 + rest/series.py | 60 +++++++++++++++ rest/wifi.py | 170 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 390 insertions(+) create mode 100644 rest/README.md create mode 100644 rest/__init__.py create mode 100644 rest/app.py create mode 100644 rest/auth.py create mode 100644 rest/config.py create mode 100644 rest/histogram.py create mode 100644 rest/requirements.txt create mode 100644 rest/series.py create mode 100644 rest/wifi.py diff --git a/rest/README.md b/rest/README.md new file mode 100644 index 0000000..0e41a17 --- /dev/null +++ b/rest/README.md @@ -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` \ No newline at end of file diff --git a/rest/__init__.py b/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rest/app.py b/rest/app.py new file mode 100644 index 0000000..7972348 --- /dev/null +++ b/rest/app.py @@ -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) diff --git a/rest/auth.py b/rest/auth.py new file mode 100644 index 0000000..1ece2f8 --- /dev/null +++ b/rest/auth.py @@ -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'} diff --git a/rest/config.py b/rest/config.py new file mode 100644 index 0000000..f81e855 --- /dev/null +++ b/rest/config.py @@ -0,0 +1,5 @@ +import configparser + + +Config = configparser.ConfigParser() +Config.read('../config/CosmicPi.config') diff --git a/rest/histogram.py b/rest/histogram.py new file mode 100644 index 0000000..2b0ee80 --- /dev/null +++ b/rest/histogram.py @@ -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() + diff --git a/rest/requirements.txt b/rest/requirements.txt new file mode 100644 index 0000000..96943cf --- /dev/null +++ b/rest/requirements.txt @@ -0,0 +1,2 @@ +flask_restful +flask_cors diff --git a/rest/series.py b/rest/series.py new file mode 100644 index 0000000..37b94db --- /dev/null +++ b/rest/series.py @@ -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 diff --git a/rest/wifi.py b/rest/wifi.py new file mode 100644 index 0000000..e1437ed --- /dev/null +++ b/rest/wifi.py @@ -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