diff --git a/src/python/pdmodels/Models.py b/src/python/pdmodels/Models.py index 278ce81f..576d1eed 100644 --- a/src/python/pdmodels/Models.py +++ b/src/python/pdmodels/Models.py @@ -39,7 +39,7 @@ class LogicalDevice(BaseModel, extra=Extra.allow): name: str location: Optional[Location] last_seen: Optional[datetime] - properties = {} + properties: Dict = {} class PhysicalToLogicalMapping(BaseModel): diff --git a/src/www/app/main.py b/src/www/app/main.py index 5e80938c..9dc77716 100644 --- a/src/www/app/main.py +++ b/src/www/app/main.py @@ -1,8 +1,11 @@ +import atexit +import time from typing import Tuple -from flask import Flask, render_template, request, make_response, redirect, url_for, session, send_from_directory +from flask import Flask, render_template, request, redirect, url_for, session, send_from_directory + import folium -import paho.mqtt.publish as publish +import paho.mqtt.client as mqtt import os from datetime import timedelta, datetime, timezone import re @@ -14,6 +17,23 @@ from pdmodels.Models import PhysicalDevice, LogicalDevice, PhysicalToLogicalMapping, Location +from logging.config import dictConfig + +dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } +}) app = Flask(__name__, static_url_path='/static') @@ -71,6 +91,64 @@ def time_since(date: datetime) -> str: return f'{seconds} seconds ago' +#------------- +# MQTT section +#------------- + +_mqtt_client = None +_wombat_config_msgs = {} + +def mqtt_on_connect(client, userdata, flags, rc): + global _mqtt_client + app.logger.info('MQTT connected') + _mqtt_client.subscribe(f'wombat/+') + +def mqtt_on_message(client, userdata, msg): + tp = msg.topic.split('/') + if len(tp) == 2: + sn = tp[1] + if len(msg.payload) > 0: + script = str(msg.payload, encoding='UTF-8') + _wombat_config_msgs[sn] = script + else: + _wombat_config_msgs[sn] = None + + +def _send_mqtt_msg_via_sn(serial_nos: List[str], msg: str | None) -> None: + """ + Publish a config script to a list of Wombats. + + Params: + serial_nos: A list of Wombat serial numbers. + msg: The config script to send, use None to clear the script. + """ + if _mqtt_client.is_connected(): + for sn in serial_nos: + _mqtt_client.publish(f'wombat/{sn}', msg, 1, True) + + +def _send_mqtt_msg_via_uids(p_uids: str | List[int], msg: str | None) -> None: + """ + Publish a config script to a list of Wombats. + + Params: + p_uids: A list of integer physical device ids or a string of the form "1,2,3". + msg: The config script to send, use None to clear the script. + """ + if _mqtt_client.is_connected(): + if isinstance(p_uids, str): + p_uids = list(map(lambda i: int(i), p_uids.split(','))) + + serial_nos = [] + for p_uid in p_uids: + pd = get_physical_device(p_uid, session.get('token')) + if pd is not None and pd.source_ids.get('serial_no', None) is not None: + serial_nos.append(pd.source_ids['serial_no']) + + _send_mqtt_msg_via_sn(serial_nos, msg) + + + """ Session cookie config """ @@ -87,7 +165,7 @@ def check_user_logged_in(): if not session.get('token'): if request.path != '/login' and request.path != '/static/main.css': # Stores the url user tried to go to in session so when they log in, we take them back to it - session['original_url'] = request.url + session['original_url'] = request.url return redirect(url_for('login'), code=302) @@ -107,10 +185,10 @@ def login(): user_token = get_user_token(username=username, password=password) session['user'] = username session['token'] = user_token - + if 'original_url' in session: return redirect(session.pop('original_url')) - + return redirect(url_for('index')) return render_template("login.html") @@ -149,37 +227,41 @@ def account(): return render_template('account.html') -@app.route('/get_wombat_logs', methods=['GET']) +@app.route('/wombats/config/logs', methods=['GET']) def get_wombat_logs(): - p_uids = list(map(lambda i: int(i), request.args['uids'].split(','))) - app.logger.info(f'get_wombat_logs for p_uids: {p_uids}') - msgs = [] - - for p_uid in p_uids: - pd = get_physical_device(p_uid, session.get('token')) - if pd is not None: - app.logger.info(f'Wombat serial no: {pd.source_ids["serial_no"]}') - msgs.append((f'wombat/{pd.source_ids["serial_no"]}', 'ftp login\nupload log.txt\nftp logout\n', 1, True)) - - # NOTE! Assuming default port of 1883. - publish.multiple(msgs, hostname=_mqtt_host, auth={'username': _mqtt_user, 'password': _mqtt_pass}) + _send_mqtt_msg_via_uids(request.args['uids'], 'ftp login\nupload log.txt\nftp logout\n') return "OK" -@app.route('/wombat_ota', methods=['GET']) +@app.route('/wombats/config/ota', methods=['GET']) def wombat_ota(): - p_uids = list(map(lambda i: int(i), request.args['uids'].split(','))) - app.logger.info(f'wombat_ota for p_uids: {p_uids}') - msgs = [] - - for p_uid in p_uids: - pd = get_physical_device(p_uid, session.get('token')) - if pd is not None: - app.logger.info(f'Wombat serial no: {pd.source_ids["serial_no"]}') - msgs.append((f'wombat/{pd.source_ids["serial_no"]}', 'config ota 1\nconfig reboot\n', 1, True)) - - # NOTE! Assuming default port of 1883. - publish.multiple(msgs, hostname=_mqtt_host, auth={'username': _mqtt_user, 'password': _mqtt_pass}) + _send_mqtt_msg_via_uids(request.args['uids'], 'config ota 1\nconfig reboot\n') + return "OK" + + +@app.route('/wombats/config/clear') +def clear_wombat_config_script(): + """ + Clear the config scripts for the Wombats identified by the sn request parameter. + + If the id request parameter is "uid" then sn must contain a list of phyiscal device ids. If id is "sn" + then sn must contain a list of Wombat serial numbers. + """ + sn = request.args['sn'] + if sn is not None: + id_type = request.args.get('id', 'id') + app.logger.info(f'Clearing config script for {sn}, id={id_type}') + + # Publish an empty retained message to clear the config script message from the topic. + if id_type == 'uid': + ids = list(map(lambda i: int(i), sn.split(','))) + app.logger.info(ids) + _send_mqtt_msg_via_uids(ids, None) + else: + ids = sn.split(',') + app.logger.info(ids) + _send_mqtt_msg_via_sn(ids, None) + return "OK" @@ -198,8 +280,10 @@ def wombats(): mappings = get_current_mappings(session.get('token')) for dev in physical_devices: + sn = dev.source_ids["serial_no"] ccid = dev.source_ids.get('ccid', None) fw_version: str = dev.source_ids.get('firmware', None) + config_script: str | None = _wombat_config_msgs.get(dev.source_ids["serial_no"], None) if ccid is not None: setattr(dev, 'ccid', ccid) @@ -207,6 +291,11 @@ def wombats(): if fw_version is not None: setattr(dev, 'fw', fw_version) + if config_script is not None: + setattr(dev, 'script', config_script) + + setattr(dev, 'sn', sn) + setattr(dev, 'ts_sort', dev.last_seen.timestamp()) dev.last_seen = time_since(dev.last_seen) @@ -422,7 +511,7 @@ def CreateMapping(): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -436,7 +525,7 @@ def CreateNote(noteText, uid): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -448,7 +537,7 @@ def DeleteNote(noteUID): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -461,7 +550,7 @@ def EditNote(noteText, uid): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -483,11 +572,11 @@ def UpdatePhysicalDevice(): update_physical_device(uid, new_name, location, token) return 'Success', 200 - + except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -499,7 +588,7 @@ def UpdateMappings(): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -527,7 +616,7 @@ def ToggleDeviceMapping(): is_active = request.args['is_active'] toggle_device_mapping(uid=uid, dev_type=dev_type, is_active=is_active, token=session.get('token')) - + return 'Success', 200 @@ -553,7 +642,7 @@ def UpdateLogicalDevice(): except requests.exceptions.HTTPError as e: if e.response.status_code == 403: return f"You do not have sufficient permissions to make this change", e.response.status_code - + return f"HTTP request with RestAPI failed with error {e.response.status_code}", e.response.status_code @@ -597,5 +686,31 @@ def generate_link(data): return link +def exit_handler(): + global _mqtt_client + + app.logger.info('Stopping') + _mqtt_client.disconnect() + + while _mqtt_client.is_connected(): + time.sleep(0.5) + + _mqtt_client.loop_stop() + app.logger.info('Done') + + if __name__ == '__main__': + app.logger.info('Starting') + _mqtt_client = mqtt.Client() + _mqtt_client.username_pw_set(_mqtt_user, _mqtt_pass) + _mqtt_client.on_connect = mqtt_on_connect + _mqtt_client.on_message = mqtt_on_message + + app.logger.info('Connecting to MQTT broker') + _mqtt_client.connect_async(_mqtt_host) + app.logger.info('Starting MQTT thread') + _mqtt_client.loop_start() + + atexit.register(exit_handler) + app.run(port='5000', host='0.0.0.0') diff --git a/src/www/app/templates/base.html b/src/www/app/templates/base.html index 889ec761..2d3703dd 100644 --- a/src/www/app/templates/base.html +++ b/src/www/app/templates/base.html @@ -14,7 +14,7 @@
    -
  • Fetch logs
  • -
  • FW OTA
  • +
  • Fetch logs
  • +
  • FW OTA
  • +
  • Clear Scripts
@@ -22,6 +91,7 @@ Current mapping Ubidots + @@ -58,6 +128,11 @@ class="material-icons">open_in_new {% endif %} + + {% if physicalDevice.script is defined %} + install_desktop + {% endif %} + {% endfor %} diff --git a/src/www/app/utils/api.py b/src/www/app/utils/api.py index cca6a66b..36bac10c 100644 --- a/src/www/app/utils/api.py +++ b/src/www/app/utils/api.py @@ -1,4 +1,4 @@ -import json +import json, logging, os from typing import List import requests from datetime import datetime, timezone @@ -6,7 +6,7 @@ from pdmodels.Models import PhysicalDevice, LogicalDevice, PhysicalToLogicalMapping, DeviceNote, Location -end_point = 'http://restapi:5687' +end_point = os.getenv('IOTA_API_URL', 'http://restapi:5687') def get_sources(token: str) -> List[str]: @@ -359,8 +359,8 @@ def change_user_password(password: str, token: str) -> str: Params: password: str - User's new password token: str - User's authentication token - - reutrn: + + return: token: str - User's new authentication token """ headers = {"Authorization": f"Bearer {token}"}