diff --git a/sensor_lab/login/__init__.py b/sensor_lab/login/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_lab/login/admin.py b/sensor_lab/login/admin.py new file mode 100644 index 0000000..4bdac86 --- /dev/null +++ b/sensor_lab/login/admin.py @@ -0,0 +1,17 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.contrib import admin + +# Register your models here. diff --git a/sensor_lab/login/apps.py b/sensor_lab/login/apps.py new file mode 100644 index 0000000..4ee788c --- /dev/null +++ b/sensor_lab/login/apps.py @@ -0,0 +1,19 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.apps import AppConfig + + +class LoginConfig(AppConfig): + name = 'login' diff --git a/sensor_lab/login/auth.py b/sensor_lab/login/auth.py new file mode 100644 index 0000000..bcead63 --- /dev/null +++ b/sensor_lab/login/auth.py @@ -0,0 +1,30 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import json + + +class DeviceCloudUser(object): + def __init__(self, server: str, username: str, password: str): + self.server = server + self.username = username + self.password = password + + def to_json(self): + return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, + indent=4) + + @classmethod + def from_json(cls, data): + return cls(**data) diff --git a/sensor_lab/login/migrations/__init__.py b/sensor_lab/login/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_lab/login/models.py b/sensor_lab/login/models.py new file mode 100644 index 0000000..d133773 --- /dev/null +++ b/sensor_lab/login/models.py @@ -0,0 +1,33 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django import forms +from django.contrib.auth.forms import AuthenticationForm + +SERVER_CHOICES = ( + ("https://remotemanager.digi.com", "Official server"), + ("https://test.idigi.com", "Test server") +) + + +class CustomAuthForm(AuthenticationForm): + server = forms.ChoiceField(choices=SERVER_CHOICES, label='') + username = forms.CharField(widget=forms.TextInput( + attrs={"class": "validate", "placeholder": "DRM Username", + "autocomplete": "username"}), label="") + password = forms.CharField(widget=forms.PasswordInput( + attrs={"placeholder": "DRM Password", + "autocomplete": "current-password"}), label="") + + field_order = ["server", "username", "password"] diff --git a/sensor_lab/login/static/css/login.css b/sensor_lab/login/static/css/login.css new file mode 100644 index 0000000..e208358 --- /dev/null +++ b/sensor_lab/login/static/css/login.css @@ -0,0 +1,242 @@ +/* GENERAL */ + +:root { + --digi-green: #84C361; + --digi-gray: #717174; +} + +form { + margin-bottom: 20px; +} + +/* STRUCTURE */ + +.wrapper { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + width: 100%; + min-height: 80%; + padding: 20px; +} + +#formContent { + -webkit-border-radius: 10px 10px 10px 10px; + border-radius: 10px 10px 10px 10px; + background: #fff; + padding: 30px; + width: 90%; + max-width: 450px; + position: relative; + -webkit-box-shadow: 0 30px 60px 0 rgba(0,0,0,0.3); + box-shadow: 0 30px 60px 0 rgba(0,0,0,0.3); + text-align: center; +} + +/* FORM TYPOGRAPHY*/ + +.login-title { + margin: 10px 0px 20px 0px; +} + +input[type=button], .login-button, input[type=reset] { + background-color: var(--digi-green); + border: none; + color: white; + padding: 12px 80px; + text-align: center; + text-decoration: none; + display: inline-block; + text-transform: uppercase; + font-size: 14px; + -webkit-box-shadow: 0 10px 30px 0 rgba(132, 195, 97, 0.4); + box-shadow: 0 10px 30px 0 rgba(132, 195, 97, 0.4); + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; + -webkit-transition: all 0.3s ease-in-out; + -moz-transition: all 0.3s ease-in-out; + -ms-transition: all 0.3s ease-in-out; + -o-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; +} + +input[type=button]:hover, .login-button:hover, input[type=reset]:hover { + background-color: #88C361; +} + +input[type=button]:active, .login-button:active, input[type=reset]:active { + -moz-transform: scale(0.95); + -webkit-transform: scale(0.95); + -o-transform: scale(0.95); + -ms-transform: scale(0.95); + transform: scale(0.95); +} + +input[type=text], input[type=password], select { + background-color: #f6f6f6; + border: none; + color: #0d0d0d; + padding: 12px 30px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 14px; + margin: 0px 0px 25px 0px; + width: 85%; + border: 2px solid #f6f6f6; + -webkit-transition: all 0.5s ease-in-out; + -moz-transition: all 0.5s ease-in-out; + -ms-transition: all 0.5s ease-in-out; + -o-transition: all 0.5s ease-in-out; + transition: all 0.5s ease-in-out; + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; +} + +input[type=text]:focus, input[type=password]:focus, select:focus { + background-color: #fff; + border-bottom: 2px solid var(--digi-green); +} + +select { + -moz-appearance:none; /* Firefox */ + -webkit-appearance:none; /* Safari and Chrome */ + appearance:none; + text-align-last: center; + background-image: url("../images/combo_arrow_gray.png"); + background-repeat: no-repeat, repeat; + background-position: right .7em top 50%; + background-size: .65em auto; +} + +select:focus { + background-image: url("../images/combo_arrow_green.png"); +} + +.login-desc, .create-account { + font-size: 12px; + color: var(--digi-gray); +} + +.errorlist { + font-size: 13px; + background-color: #FFDEDE; + border-radius: 10px; + padding: 10px; + color: #C40000; + border: 1px solid #C40000; + list-style-type: none; +} + +/* ANIMATIONS */ + +/* Simple CSS3 Fade-in-down Animation */ +.fadeInDown { + -webkit-animation-name: fadeInDown; + animation-name: fadeInDown; + -webkit-animation-duration: 500ms; + animation-duration: 500ms; + -webkit-animation-fill-mode: both; + animation-fill-mode: both; +} + +@-webkit-keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@keyframes fadeInDown { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +/* Simple CSS3 Fade-in Animation */ +@-webkit-keyframes fadeIn { from { opacity:0; } to { opacity:1; } } +@-moz-keyframes fadeIn { from { opacity:0; } to { opacity:1; } } +@keyframes fadeIn { from { opacity:0; } to { opacity:1; } } + +.fadeIn { + opacity:0; + -webkit-animation:fadeIn ease-in 1; + -moz-animation:fadeIn ease-in 1; + animation:fadeIn ease-in 1; + + -webkit-animation-fill-mode:forwards; + -moz-animation-fill-mode:forwards; + animation-fill-mode:forwards; + + -webkit-animation-duration:1s; + -moz-animation-duration:1s; + animation-duration:1s; +} + +.fadeIn.first { + -webkit-animation-delay: 0.4s; + -moz-animation-delay: 0.4s; + animation-delay: 0.4s; +} + +.fadeIn.second { + -webkit-animation-delay: 0.6s; + -moz-animation-delay: 0.6s; + animation-delay: 0.6s; +} + +.fadeIn.third { + -webkit-animation-delay: 0.8s; + -moz-animation-delay: 0.8s; + animation-delay: 0.8s; +} + +.fadeIn.fourth { + -webkit-animation-delay: 1s; + -moz-animation-delay: 1s; + animation-delay: 1s; +} + +/* Simple CSS3 Fade-in Animation */ +.underlineHover:after { + display: block; + left: 0; + bottom: -10px; + width: 0; + height: 2px; + background-color: #56baed; + content: ""; + transition: width 0.2s; +} + +.underlineHover:hover { + color: #0d0d0d; +} + +.underlineHover:hover:after{ + width: 100%; +} + +/* OTHERS */ + +*:focus { + outline: none; +} + +#icon { + width:60%; +} diff --git a/sensor_lab/login/templates/login.html b/sensor_lab/login/templates/login.html new file mode 100644 index 0000000..9541113 --- /dev/null +++ b/sensor_lab/login/templates/login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %} +Digi XBee Sensor Lab - Login +{% endblock %} +{% block page_content %} +
+
+ + +
+ {% csrf_token %} + {{ form.as_table }} + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/sensor_lab/login/templates/logout.html b/sensor_lab/login/templates/logout.html new file mode 100644 index 0000000..4a80965 --- /dev/null +++ b/sensor_lab/login/templates/logout.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %} +Digi XBee Sensor Lab - Logout +{% endblock %} +{% block page_content %} +
+
+ +

+ Didn't want to log out? Log in again. +

+
+
+{% endblock %} \ No newline at end of file diff --git a/sensor_lab/login/tests.py b/sensor_lab/login/tests.py new file mode 100644 index 0000000..805ba4c --- /dev/null +++ b/sensor_lab/login/tests.py @@ -0,0 +1,17 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.test import TestCase + +# Create your tests here. diff --git a/sensor_lab/login/urls.py b/sensor_lab/login/urls.py new file mode 100644 index 0000000..4734f3c --- /dev/null +++ b/sensor_lab/login/urls.py @@ -0,0 +1,22 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.urls import path +from . import views + + +urlpatterns = [ + path("login/", views.login, name="login"), + path("logout/", views.logout, name="logout"), +] diff --git a/sensor_lab/login/views.py b/sensor_lab/login/views.py new file mode 100644 index 0000000..d1e0d54 --- /dev/null +++ b/sensor_lab/login/views.py @@ -0,0 +1,82 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from devicecloud import DeviceCloud +from django.shortcuts import render, redirect + +from login.auth import DeviceCloudUser +from login.models import CustomAuthForm + +PARAM_DEST = "dest" + + +def login(request): + # Create an empty authentication form. + form = CustomAuthForm() + if request.method == "POST": + # Add received data to form. + form = CustomAuthForm(data=request.POST) + + # Retrieve the credentials. + server = form.data["server"] + username = form.data["username"] + password = form.data["password"] + + # Validate credentials. + dc = request.session.get("dc") + if not dc: + dc = DeviceCloud(username, password, base_url=server) + + # If the user exists, do manual login and redirect to main page. + if dc is not None and dc.has_valid_credentials(): + user = DeviceCloudUser(server, username, password) + request.session["user"] = user.to_json() + request.session["devices"] = {} + request.session.modified = True + return redirect_dest(request) + + return render(request, "login.html", {'form': form}) + + +def logout(request): + # End session. + if request.session.get("user") is None: + # Redirect to init page. + return redirect("/access/login") + + # Redirect to logout page. + request.session["user"] = None + request.session["devices"] = None + return render(request, "logout.html") + + +def redirect_dest(request): + """ + Redirects to the destination page based on the request arguments. + + Args: + request (:class:`.WSGIRequest`): The HTTP request. + + Returns: + An `HttpResponseRedirect` to the destination page. + """ + url = "/" + if PARAM_DEST in request.GET: + url += "{}/?".format(request.GET[PARAM_DEST]) + args = "" + for arg in request.GET: + if arg != PARAM_DEST: + args += "{}={}&".format(arg, request.GET[arg]) + url += args[0:len(args) - 1] + return redirect(url) diff --git a/sensor_lab/manage.py b/sensor_lab/manage.py new file mode 100644 index 0000000..e2a2260 --- /dev/null +++ b/sensor_lab/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sensorlabcommon.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/sensor_lab/requirements.txt b/sensor_lab/requirements.txt new file mode 100644 index 0000000..c8aaf99 --- /dev/null +++ b/sensor_lab/requirements.txt @@ -0,0 +1,3 @@ +Django==3.1 +devicecloud==0.5.9 +channels==3.0.4 \ No newline at end of file diff --git a/sensor_lab/run_web_app.py b/sensor_lab/run_web_app.py new file mode 100644 index 0000000..18ac45c --- /dev/null +++ b/sensor_lab/run_web_app.py @@ -0,0 +1,338 @@ +# Copyright 2022, Digi International Inc. +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import argparse +import os +import pathlib +import platform +import subprocess +import sys +import venv +import re +import webbrowser + +# Constants. +FOLDER_DEMO = "." +FOLDER_VENV = ".venv" +FOLDER_WHEELS = "../digi_wheels" + +FILE_REQUIREMENTS = "requirements.txt" +FILE_MANAGE = "manage.py" +FILE_CONFIGURED = ".configured" + +PY_VENV_LINUX = "python%d.%d-venv" + +PATTERN_SERVER = "^Starting (?:.*)development server at (.*)$" + +TWISTED_32 = "Twisted-20.3.0-cp{0}{1}-cp{0}{1}-win32.whl" +TWISTED_64 = "Twisted-20.3.0-cp{0}{1}-cp{0}{1}-win_amd64.whl" + +# Variables. +null_output = open(os.devnull, "w") + + +def create_venv(venv_path): + """ + Creates a virtual environment in the given path. + + Args: + venv_path (String): The path of the virtual environment to create. + + Returns: + :class:`SimpleNamespace`: The context of the virtual environment + generated. It contains information about the virtual environment + such as python executable, binaries folder, etc. + """ + # Verify the virtual environment exists before creating it. + venv_context = get_venv(venv_path) + if venv_context is None: + venv_builder = venv.EnvBuilder(with_pip=True) + venv_builder.create(venv_path) + return venv_builder.ensure_directories(venv_path) + else: + return venv_context + + +def get_venv(venv_path): + """ + Gets the context of the virtual environment located in the given path. + + Args: + venv_path (String): The path of the virtual environment to get its + context. + + Returns: + :class:`SimpleNamespace`: The context of the virtual environment + of the demo. It contains information about the virtual environment + such as python executable, binaries folder, etc. `None` if there + is not a valid environment in that path. + """ + venv_builder = venv.EnvBuilder(with_pip=True) + + # Get the virtual environment context. + venv_context = venv_builder.ensure_directories(venv_path) + if not os.path.exists(venv_context.env_exe): + return None + + return venv_context + + +def run_python_cmd(command, enable_debug=True): + """ + Executes the given command using the installed Python interpreter used to + run this script. + + Args: + command (List): The command to execute. Contains the Python file name + and arguments. + enable_debug (Boolean): `True` to display the output of the command, + `False` otherwise. + + Returns: + Integer: The status code of the execution. + """ + return subprocess.check_call(command, + stdout=None if enable_debug else null_output, + stderr=None if enable_debug else null_output) + + +def run_venv_python(venv_context, command, enable_debug=True): + """ + Executes the given command using the Python interpreter of the virtual + environment. + + Args: + venv_context (:class:`SimpleNamespace`): The context of the virtual + environment. + command (List): The command to execute. Contains the Python file name + and arguments. + enable_debug (Boolean): `True` to display the output of the command, + `False` otherwise. + + Returns: + Integer: The status code of the execution. + """ + command = [venv_context.env_exe] + command + return subprocess.check_call(command, + stdout=None if enable_debug else null_output, + stderr=None if enable_debug else null_output) + + +def run_venv_script(venv_context, command, enable_debug=True): + """ + Executes a a script of the virtual environment. + + Args: + venv_context (:class:`SimpleNamespace`): The context of the virtual + environment. + command (List): The command to execute. Contains the script name + and arguments. + enable_debug (Boolean): `True` to display the output of the command, + `False` otherwise. + + Returns: + Integer: The status code of the execution. + """ + # Replace the script with the virtual environment's one. + command[0] = str(pathlib.Path(venv_context.bin_path).joinpath(command[0])) + # Execute the command. + return subprocess.check_call(command, + stdout=None if enable_debug else null_output, + stderr=None if enable_debug else null_output) + + +def run_web_server(venv_context, command): + """ + Executes the command to run the WEB server and parser the log to open + the WEB browser. + + Args: + venv_context (:class:`SimpleNamespace`): The context of the virtual + environment. + command (List): The run WEB server command. + """ + # Set the PYTHONUNBUFFERED environment variable so when running the script + # from CLI the URL of the server is printed and thus, opened in the browser. + env = dict(os.environ, **{'PYTHONUNBUFFERED': '1'}) + + command = [venv_context.env_exe, "-u"] + command + process = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, env=env) + + try: + with process: + for line in process.stdout: + if process.poll() is not None: + break + if line: + output_text = str(line, "utf-8").rstrip() + print(3 * " " + "> " + output_text) + sys.stdout.flush() + match = re.search(PATTERN_SERVER, output_text) + if match: + url = match.group(1) + webbrowser.open(url, new=2) + except KeyboardInterrupt: + process.terminate() + process.kill() + + +def is_64_bits_python(): + """ + Returns whether the python architecture is 64 bits or not. + + Returns: + Boolean: `True` if the python architecture is 64 bits, or `False` + otherwise. + """ + return platform.architecture()[0] == "64bit" + + +def print_success(): + """ + Prints success message. + """ + print("[OK]") + + +def print_error(error_message=None): + """ + Prints success message. + """ + print("[ERROR]") + if error_message is not None: + print(" - %s" % error_message) + + +def main(): + """ + Main script execution. + """ + # Get script arguments. + parser = argparse.ArgumentParser() + parser.add_argument("-d", "--debug", action='store_true', + help="enable debug") + args = parser.parse_args() + debug = args.debug + + # Get Python version. + py_major_version = sys.version_info[0] + py_minor_version = sys.version_info[1] + + # Print header. + print(" +----------------------+") + print(" | Digi XBee Sensor Lab |") + print(" +----------------------+") + print("") + if py_major_version < 3 or py_minor_version < 5: + print_error("Python 3.5 required to launch this script.") + sys.exit(-1) + print(" Please, wait while the script prepares the virtual environment\n" + " and runs the WEB server...") + print("") + + # Get the project folders and files. + print("- Checking project structure... ", end="") + project_dir = os.path.dirname(os.path.abspath(__file__)) + demo_dir = os.path.join(project_dir, FOLDER_DEMO) + requirements_file = os.path.join(demo_dir, FILE_REQUIREMENTS) + manage_file = os.path.join(demo_dir, FILE_MANAGE) + configured_file = os.path.join(project_dir, FILE_CONFIGURED) + if not os.path.exists(demo_dir): + print_error("Could not find the '%s' folder." % FOLDER_DEMO) + sys.exit(-1) + if not os.path.exists(requirements_file): + print_error("Could not find the demo requirements file.") + sys.exit(-1) + if not os.path.exists(manage_file): + print_error("Could not find the '%s' script." % FILE_MANAGE) + sys.exit(-1) + configured = os.path.exists(configured_file) + print_success() + + # Check if the virtual environment exits. + venv_path = os.path.join(project_dir, FOLDER_VENV) + venv_context = get_venv(venv_path) + if venv_context is None: + configured = False + + # Create the virtual environment. + if not configured: + print("- Generating virtual environment... ", end="") + sys.stdout.flush() + # If this is a Linux distribution, install the correct venv package for + # the selected python interpreter. + if sys.platform == "linux" or sys.platform == "linux2": + run_python_cmd(["sudo", "apt-get", "--yes", "install", + PY_VENV_LINUX % ( + py_major_version, py_minor_version)]) + + venv_context = create_venv(venv_path) + print_success() + + # Install pip in the virtual environment + print("- Installing basic modules... ", end="") + sys.stdout.flush() + if run_venv_python(venv_context, ['-m', 'pip', 'install', '-U', 'pip'], + debug) != 0: + print_error() + sys.exit(-1) + print_success() + + # Install the application requirements. + print("- Installing application requirements: ") + with open(requirements_file) as f: + for line in f: + line = line.strip() + if line == "": + continue + print(" - Installing module '%s'... " % line, + end="\n" if debug else "") + sys.stdout.flush() + if run_venv_script(venv_context, ['pip', 'install', line], + debug) != 0: + print_error() + sys.exit(-1) + print_success() + + # Generate the configured mark in the project path. + open(configured_file, 'a').close() + + # Change working dir. + os.chdir(demo_dir) + + # Initialize Django database. + if not configured: + print("- Initializing Django database... ", + end="\n" if debug else "") + sys.stdout.flush() + if run_venv_python(venv_context, [manage_file, "makemigrations"], + debug) != 0: + print_error("There was an error with the 'makemigrations' " + "command.") + sys.exit(-1) + if run_venv_python(venv_context, [manage_file, "migrate"], debug) != 0: + print_error("There was an error with the 'migrate' command.") + sys.exit(-1) + print_success() + + # Launch WEB server. + print("- Running WEB server... ") + print("") + sys.stdout.flush() + run_web_server(venv_context, [manage_file, "runserver"]) + + +if __name__ == '__main__': + main() diff --git a/sensor_lab/sensorlabcommon/__init__.py b/sensor_lab/sensorlabcommon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_lab/sensorlabcommon/asgi.py b/sensor_lab/sensorlabcommon/asgi.py new file mode 100644 index 0000000..6a938ed --- /dev/null +++ b/sensor_lab/sensorlabcommon/asgi.py @@ -0,0 +1,36 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" +ASGI config for XBee Sensor Lab project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os +import sensorlabcore.routing + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sensorlabcommon.settings') + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack(URLRouter(sensorlabcore.routing.websocket_urlpatterns)) +}) diff --git a/sensor_lab/sensorlabcommon/settings.py b/sensor_lab/sensorlabcommon/settings.py new file mode 100644 index 0000000..6c01acc --- /dev/null +++ b/sensor_lab/sensorlabcommon/settings.py @@ -0,0 +1,181 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" +Django settings for XBee Sensor Lab project. + +Generated by 'django-admin startproject' using Django 3.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" + +import os + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-)+4dqr3il6-576viwqqe793%u)8y#&)p#q2n150^&kj=ztwd7n' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'login', + 'sensorlabcore', + 'channels', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'sensorlabcommon.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['sensorlabcommon/templates/'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'sensorlabcommon.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = '/static_files/' + +# STATIC_ROOT = os.path.join(BASE_DIR, "static_files/") +# STATICFILES_DIRS = [ +# os.path.join(BASE_DIR, "static_files"), +# ] +if DEBUG: + STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static_files/') + ] +else: + STATIC_ROOT = os.path.join(BASE_DIR, "static_files/") + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Channels +ASGI_APPLICATION = 'sensorlabcommon.asgi.application' + +CHANNEL_LAYERS = { + "default": { + ### Method 1: Via redis lab + # 'BACKEND': 'channels_redis.core.RedisChannelLayer', + # 'CONFIG': { + # "hosts": [ + # 'redis://h:;@:' + # ], + # }, + + ### Method 2: Via local Redis + # 'BACKEND': 'channels_redis.core.RedisChannelLayer', + # 'CONFIG': { + # "hosts": [('127.0.0.1', 6379)], + # }, + + ### Method 3: Via In-memory channel layer + ## WARNING!!! This is not recommended for production + "BACKEND": "channels.layers.InMemoryChannelLayer" + }, +} diff --git a/sensor_lab/sensorlabcommon/templates/base.html b/sensor_lab/sensorlabcommon/templates/base.html new file mode 100644 index 0000000..89b384c --- /dev/null +++ b/sensor_lab/sensorlabcommon/templates/base.html @@ -0,0 +1,77 @@ + + + + {% block title %}{% endblock %} + + + + {% load static %} + + + + + + + + + + + + + + + + + + +
+
+ {% block page_content %}{% endblock %} +
+
+ + + {% block javascript_outer %}{% endblock %} + + \ No newline at end of file diff --git a/sensor_lab/sensorlabcommon/templates/base_logged_in.html b/sensor_lab/sensorlabcommon/templates/base_logged_in.html new file mode 100644 index 0000000..3167080 --- /dev/null +++ b/sensor_lab/sensorlabcommon/templates/base_logged_in.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% load static %} + +{% block profile %} + +{% endblock %} + +{% block javascript_outer %} + +{% block javascript %}{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/sensor_lab/sensorlabcommon/urls.py b/sensor_lab/sensorlabcommon/urls.py new file mode 100644 index 0000000..8f39905 --- /dev/null +++ b/sensor_lab/sensorlabcommon/urls.py @@ -0,0 +1,39 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +"""XBee Sensor Lab URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path("access/", include("login.urls")), + + path("", include("sensorlabcore.urls")), + + path('admin/', admin.site.urls), +] diff --git a/sensor_lab/sensorlabcommon/wsgi.py b/sensor_lab/sensorlabcommon/wsgi.py new file mode 100644 index 0000000..29eb86c --- /dev/null +++ b/sensor_lab/sensorlabcommon/wsgi.py @@ -0,0 +1,30 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" +WSGI config for XBee Sensor Lab project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sensorlabcommon.settings') + +application = get_wsgi_application() diff --git a/sensor_lab/sensorlabcore/__init__.py b/sensor_lab/sensorlabcore/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_lab/sensorlabcore/admin.py b/sensor_lab/sensorlabcore/admin.py new file mode 100644 index 0000000..4bdac86 --- /dev/null +++ b/sensor_lab/sensorlabcore/admin.py @@ -0,0 +1,17 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.contrib import admin + +# Register your models here. diff --git a/sensor_lab/sensorlabcore/apps.py b/sensor_lab/sensorlabcore/apps.py new file mode 100644 index 0000000..bc8093a --- /dev/null +++ b/sensor_lab/sensorlabcore/apps.py @@ -0,0 +1,19 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.apps import AppConfig + + +class SensorLabCoreConfig(AppConfig): + name = 'sensorlabcore' diff --git a/sensor_lab/sensorlabcore/consumers.py b/sensor_lab/sensorlabcore/consumers.py new file mode 100644 index 0000000..ce2e4c2 --- /dev/null +++ b/sensor_lab/sensorlabcore/consumers.py @@ -0,0 +1,67 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import json + +from channels.generic.websocket import WebsocketConsumer +from sensorlabcore import drm_requests + +# Constants. +ERROR_REGISTER_DATAPOINT_MONITOR = "ERROR: could not register data point monitor - %s" + +ID_ERROR = "error" +ID_MONITOR_ID = "monitor_id" + +TEMPLATE_ERROR = "{" \ + " \"type\": \"error\"," \ + " \"error\": \"%s\"" \ + "}" + + +class DataPointConsumer(WebsocketConsumer): + """ + Class to manage web socket connection for device Data Points. + """ + def __init__(self): + WebsocketConsumer.__init__(self) + self._monitor_id = -1 + + def connect(self): + # Initialize variables. + session = self.scope["session"] + # Sanity checks. + if session is None: + return + # Accept the connection. + self.accept() + + def disconnect(self, close_code): + if self._monitor_id != -1: + # Unsubscribe CLI monitor. + drm_requests.remove_datapoints_monitor(self.scope["session"], self._monitor_id) + self._monitor_id = -1 + + def receive(self, text_data=None, bytes_data=None): + if self._monitor_id != -1: + return + # Get data streams. + data_streams = json.loads(text_data) + # Subscribe Data Point monitor. + answer = drm_requests.register_datapoints_monitor(self.scope["session"], data_streams, self) + # Check errors. + if ID_ERROR in answer: + self._monitor_id = -1 + self.send(text_data=TEMPLATE_ERROR % ERROR_REGISTER_DATAPOINT_MONITOR % answer[ID_ERROR]) + return + self._monitor_id = answer[ID_MONITOR_ID] \ No newline at end of file diff --git a/sensor_lab/sensorlabcore/drm_requests.py b/sensor_lab/sensorlabcore/drm_requests.py new file mode 100644 index 0000000..53b9e35 --- /dev/null +++ b/sensor_lab/sensorlabcore/drm_requests.py @@ -0,0 +1,372 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import json +import re +import textwrap +import xml.etree.ElementTree as elementTree + +from devicecloud import DeviceCloud, DeviceCloudHttpException, DeviceCloudException +from devicecloud.monitor import MonitorAPI, TCPDeviceCloudMonitor, MON_TRANSPORT_TYPE_ATTR, MON_STATUS_ATTR +from devicecloud.monitor_tcp import TCPClientManager +from django.http import JsonResponse +from login.auth import DeviceCloudUser +from sensorlabcore.models import XBeeCellularDevice + +# Constants. +CELLULAR_VENDOR_ID = "4261412870" + +DATA_POINTS_BUFFER_DURATION = 5 +DATA_POINTS_BUFFER_SIZE = 10 + +ERROR_NO_POST_REQUEST = "AJAX request must be sent using POST" +ERROR_NOT_AUTHENTICATED = "Not authenticated" +ERROR_REFRESH_DATA_POINTS = "Error refreshing sensor data: %s" + +ID_DATA_STREAMS = "data_streams" +ID_READ_DEVICES = "read_devices" +ID_READ_SENSOR_TYPES = "read_sensor_types" +ID_ERROR = "error" +ID_ERROR_MESSAGE = "error_message" +ID_MONITOR_ID = "monitor_id" +ID_STREAM = "stream" +ID_VALUE = "value" + +REGEX_MONITOR_ERROR = ".*(.*)<\\/error>.*" + +SCHEMA_MONITOR_DP_FILTER = '{{#eachFiltered this}}' \ + '{{#endsWith DataPoint.streamId "%s"}}' \ + '{{#if @first}}' \ + '{' \ + '"stream": "{{DataPoint.streamId}}",' \ + '"value": {{DataPoint.data}} ' \ + '}@@SEPARATOR@@' \ + '{{/if}}' \ + '{{/endsWith}}' \ + '{{/eachFiltered}}' + +WS_MONITOR_API = "/ws/Monitor/{}" + +# Variables. +monitor_managers = {} + + +def is_authenticated(request): + """ + Returns whether the user is authenticated or not. + + Args: + request (:class:`.WSGIRequest`): The request used to verify if the + user is authenticated or not. + + Returns: + boolean: `True` if the user stored in the request session is + authenticated, `False`otherwise. + """ + dc = get_device_cloud(request) + if dc is not None and dc is not False and dc.has_valid_credentials(): + return True + return False + + +def get_device_cloud(request): + """ + Returns the Device Cloud instance for the given request. + + Args: + request (:class:`.WSGIRequest`): The request containing the user and + password to generate the corresponding Device Cloud instance. + + Returns: + :class:`.DeviceCloud`: The Device Cloud instance for the corresponding + user and password stored in the request session. + """ + return get_device_cloud_session(request.session) + + +def get_device_cloud_session(session): + """ + Returns the Device Cloud instance for the given session. + + Args: + session (:class:`.SessionStore`): The Django session containing the + user and password to generate the corresponding Device Cloud + instance. + + Returns: + :class:`.DeviceCloud`: The Device Cloud instance for the corresponding + user and password stored in the session. + """ + user = session.get("user") + if user is None: + return None + user_serialized = DeviceCloudUser.from_json(json.loads(user)) + return DeviceCloud(user_serialized.username, user_serialized.password, + base_url=user_serialized.server) + + +def get_cellular_devices(request): + """ + Returns a list containing the XBee Cellular devices of the DRM account. + + Args: + request (:class:`.WSGIRequest`): The request used to generate the + Device Cloud instance. + + Returns: + Dictionary: A dictionary containing the list of XBee Cellular devices + within the DRM account with their connection status + """ + drm_devices = [] + dc = get_device_cloud(request) + devices = list(dc.devicecore.get_devices()) + for device in devices: + # Get the type of the device and verify it is a ConnectCore device. + if device.get_vendor_id() != CELLULAR_VENDOR_ID: + continue + + drm_devices.append(XBeeCellularDevice(device.get_connectware_id(), + device.is_connected())) + + return drm_devices + + +def check_ajax_request(request): + """ + Checks whether the given AJAX request is valid and the user is + authenticated. + + Args: + request (:class:`.WSGIRequest`): The HTTP request. + + Returns: + `None` if the request is valid, or a `JsonResponse` with the error + if it is not. + """ + if is_authenticated(request): + if not request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest' or request.method != "POST": + return JsonResponse({ID_ERROR: ERROR_NO_POST_REQUEST}, status=400) + return None + else: + return JsonResponse({ID_ERROR: ERROR_NOT_AUTHENTICATED}, status=401) + + +def get_data_points(request, stream_ids): + """ + Returns the latest data point of the given list of data streams in JSON + format. + + Args: + request (:class:`.WSGIRequest`): the AJAX request. + stream_ids (List): the list of data streams. + + Returns: + A JSON with the data points or the error. + """ + # Check if the AJAX request is valid. + error = check_ajax_request(request) + if error is not None: + return error + + # Obtain the list of data stream objects. + dc = get_device_cloud(request) + data_streams = {} + answer = {} + try: + for stream_id in stream_ids: + data_stream = dc.streams.get_stream(stream_id) + data_streams[stream_id] = data_stream.get_current_value().get_data() + answer[ID_DATA_STREAMS] = data_streams + except DeviceCloudException as e: + answer[ID_ERROR] = ERROR_REFRESH_DATA_POINTS % str(e) + + return answer + + +def register_datapoints_monitor(session, stream_ids, consumer): + """ + Creates a Device Cloud monitor to be notified when the given device + uploads a new data point. + + Args: + session (:class:`.SessionStore`): The Django session. + stream_ids (List): List of data stream IDs to monitor. + consumer (:class:`.WsConsumer`): The web socket consumer. + + Returns: + The ID of the created monitor. + """ + # Initialize variables. + answer = {} + topic = "DataPoint/" + dc = get_device_cloud_session(session) + if dc is None: + return -1 + + global monitor_managers + + # Get or create the monitor manager for the given session. + session_key = session.session_key + if session_key in monitor_managers: + monitor_manager = monitor_managers.get(session_key) + else: + monitor_manager = MonitorManager(dc.get_connection()) + monitor_managers[session_key] = monitor_manager + + # Clean inactive monitors. + remove_inactive_monitors(dc, "DataPoint") + + # Build the monitor schema. + schema = "[" + for stream in stream_ids: + if stream_ids.index(stream) != len(stream_ids) - 1: + # Separate data point entries with comma. + schema = schema + SCHEMA_MONITOR_DP_FILTER.replace("@@SEPARATOR@@", ",") % stream + else: + # Remove last datapoint separator to avoid JSON error. + schema = schema + SCHEMA_MONITOR_DP_FILTER.replace("@@SEPARATOR@@", "") % stream + schema = schema + "]" + + # Create the monitor to receive data points updates. + try: + monitor = monitor_manager.create_tcp_monitor_with_schema([topic], + schema, + batch_size=len(stream_ids) * DATA_POINTS_BUFFER_SIZE, + batch_duration=DATA_POINTS_BUFFER_DURATION) + + # Define the monitor callback. + def monitor_callback(json_data): + for data_point in json_data: + # Sanity checks. + if ID_VALUE not in data_point or ID_STREAM not in data_point: + continue + # Push new data point to the web socket. + consumer.send(text_data=json.dumps(data_point)) + return True + + # Add the monitor callback. + monitor.add_callback(monitor_callback) + # Save the monitor ID. + answer[ID_MONITOR_ID] = monitor.get_id() + except Exception as e: + re_search = re.search(REGEX_MONITOR_ERROR, str(e), re.IGNORECASE) + if re_search: + answer[ID_ERROR] = re_search.group(1) + else: + answer[ID_ERROR] = str(e) + + return answer + + +def remove_datapoints_monitor(session, monitor_id): + """ + Disconnects and deletes the Device Cloud monitor with the given ID that was + listening for data point changes. + + Args: + session (:class:`.SessionStore`): The Django session. + monitor_id (int): The ID of the monitor to delete. + """ + dc = get_device_cloud_session(session) + if dc is None: + return + + global monitor_managers + + # Get or create the monitor manager for the given session. + session_key = session.session_key + if session_key not in monitor_managers: + return + monitor_manager = monitor_managers.pop(session_key) + + # Stop the monitor. + monitor_manager.stop_listeners() + + # Delete the monitor. + try: + dc.get_connection().delete(WS_MONITOR_API.format(monitor_id)) + except DeviceCloudHttpException as e: + print(e) + + +def remove_inactive_monitors(dc, topic_hint, device_id=None): + """ + Removes inactive Remote Manager monitors containing the given topic hint and device ID. + + Args: + dc (:class:`.DeviceCloud`): The Device Cloud instance. + topic_hint (String): Hint that must be included in the inactive monitor topic. + device_id (String): Device ID that must be contained in the inactive monitor topic. + """ + # Clean inactive monitors. + monitors = dc.monitor.get_monitors(MON_TRANSPORT_TYPE_ATTR == "tcp" and MON_STATUS_ATTR == "INACTIVE") + for monitor in monitors: + if topic_hint in monitor.get_metadata()["monTopic"]: + del_monitor = True + if device_id and device_id not in monitor.get_metadata()["monTopic"]: + del_monitor = False + if del_monitor: + print("Deleted inactive monitor %s" % monitor.get_metadata()["monId"]) + monitor.delete() + + +class MonitorManager(MonitorAPI): + """ + Class used to manage the use of Device Cloud monitors. + """ + + def __init__(self, conn): + MonitorAPI.__init__(self, conn) + self._tcp_client_manager = TCPClientManager(self._conn, secure=False) + + def create_tcp_monitor_with_schema(self, topics, schema, batch_size=1, batch_duration=0, + compression='gzip', format_type='json'): + """Creates a TCP Monitor instance in Device Cloud for a given list of topics + + :param topics: a string list of topics (e.g. ['DeviceCore[U]', + 'FileDataCore']). + :param schema: a string specifying the handlebars schema for the monitor push requests. + :param batch_size: How many Msgs received before sending data. + :param batch_duration: How long to wait before sending batch if it + does not exceed batch_size. + :param compression: Compression value (i.e. 'gzip'). + :param format_type: What format server should send data in (i.e. 'xml' or 'json'). + + Returns an object of the created Monitor + """ + + monitor_xml = """\ + + {topics} + {batch_size} + {batch_duration} + {format_type} + tcp + {compression} + handlebars + {schema} + + """.format( + topics=','.join(topics), + batch_size=batch_size, + batch_duration=batch_duration, + format_type=format_type, + compression=compression, + schema=schema, + ) + monitor_xml = textwrap.dedent(monitor_xml) + response = self._conn.post("/ws/Monitor", monitor_xml) + location = elementTree.fromstring(response.text).find('.//location').text + monitor_id = int(location.split('/')[-1]) + return TCPDeviceCloudMonitor(self._conn, monitor_id, self._tcp_client_manager) \ No newline at end of file diff --git a/sensor_lab/sensorlabcore/migrations/__init__.py b/sensor_lab/sensorlabcore/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sensor_lab/sensorlabcore/models.py b/sensor_lab/sensorlabcore/models.py new file mode 100644 index 0000000..ea9e3d4 --- /dev/null +++ b/sensor_lab/sensorlabcore/models.py @@ -0,0 +1,268 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from enum import Enum + +# Constants. +WIDGET_SCALE = """ +
+
+ +
+
+
+ - +
+
+ Kg +
+
+
+""" +WIDGET_SLIDER = """""" +WIDGET_ATMOSPHERE = """""" +WIDGET_AIR = """""" +WIDGET_LIGHT = """""" +WIDGET_MOISTURE = """""" +WIDGET_DISTANCE = """""" +WIDGET_NOISE = """""" +WIDGET_TILT = """""" +WIDGET_TEMP_HUM = """ +
+
+ +
+
+ - +
+
+ C +
+
+
+
+ +
+
+ - +
+
+ % +
+
+""" + + +class XBeeCellularDevice: + def __init__(self, device_id, online): + """ + Class constructor. Instantiates a new ``XBeeCellularDevice``. + + Args: + device_id (String): The ID of the device. + online (Boolean): Whether the device is online or not. + """ + self._device_id = device_id + self._online = online + + @property + def device_id(self): + """ + Returns the device ID of the XBee Cellular device. + + Returns: + String: The device ID of the XBee Cellular device. + """ + return self._device_id + + @property + def online(self): + """ + Returns whether the device is online or not. + + Returns: + Boolean: Whether the device is online or not. + """ + return self._online + + def to_json(self): + """ + Returns the object in JSON format. + + Returns: + The XBee Cellular device in JSON format. + """ + json_dict = { + "device_id": self._device_id, + "online": self._online + } + return json_dict + + +class XBeeSensor: + def __init__(self, name, xbee_device, sensor_type): + """ + Class constructor. Instantiates a new ``XBeeSensor``. + + Args: + name (String): The name of the XBee sensor. + xbee_device (:class:`.SensorType`:): The XBee device Cellular + device the sensor is attached to. + sensor_type (SensorType): The type of XBee sensor. + """ + self._name = name + self._xbee_device = xbee_device + self._sensor_type = sensor_type + + @property + def name(self): + """ + Returns the name of the XBee sensor. + + Returns: + String: The name of the XBee sensor. + """ + return self._name + + @property + def xbee_device(self): + """ + Returns the XBee Cellular device the sensor is attached to. + + Returns: + :class:`.SensorType`: The XBee Cellular device the sensor is + attached to. + """ + return self._xbee_device + + @property + def sensor_type(self): + """ + Returns the type of sensor. + + Returns: + :class:`.SensorType`: The tye of sensor. + """ + return self._sensor_type + + def to_json(self): + """ + Returns the object in JSON format. + + Returns: + The XBee sensor in JSON format. + """ + json_dict = { + "name": self._name, + "xbee_device": self._xbee_device.to_json(), + "sensor_type": self._sensor_type.to_json() + } + return json_dict + + +class SensorType(Enum): + """ + Enumerates the available XBee sensor types. + + | Inherited properties: + | **name** (String): the name (id) of this XBeeProtocol. + | **value** (String): the value of this XBeeProtocol. + """ + SCALE = (0, "Scale", WIDGET_SCALE, ["xbsl/scale"]) + SLIDER = (1, "Slider", WIDGET_SLIDER, ["xbsl/slider"]) + ATMOSPHERE = (2, "Atmosphere", WIDGET_ATMOSPHERE, ["xbsl/atmosphere/pressure", + "xbsl/atmosphere/humidity", + "xbsl/atmosphere/temperature"]) + AIR = (3, "Air quality", WIDGET_AIR, ["xbsl/air/co2", + "xbsl/air/tvoc", + "xbsl/air/h2", + "xbsl/air/ethanol"]) + LIGHT = (4, "Light level", WIDGET_LIGHT, ["xbsl/light"]) + MOISTURE = (5, "Soil moisture", WIDGET_MOISTURE, ["xbsl/moisture"]) + DISTANCE = (6, "Distance", WIDGET_DISTANCE, ["xbsl/distance"]) + NOISE = (7, "Noise level", WIDGET_NOISE, ["xbsl/noise"]) + TILT = (8, "Tilt", WIDGET_TILT, ["xbsl/tilt"]) + TEMP_HUM = (9, "XBIBC Board", WIDGET_TEMP_HUM, ["xbsl/xbibc/temperature", + "xbsl/xbibc/humidity"]) + + def __init__(self, code, name, html_widget, stream_ids): + self._code = code + self._name = name + self._html_widget = html_widget + self._stream_ids = stream_ids + + @property + def code(self): + """ + Returns the code of the SensorType element. + + Returns: + Integer: The code of the SensorType element. + """ + return self._code + + @property + def name(self): + """ + Returns the name of the SensorType element. + + Returns: + String: The name of the SensorType element. + """ + return self._name + + @property + def html_widget(self): + """ + Returns the HTML widget code of the SensorType element. + + Returns: + String: The HTML widget code of the SensorType element. + """ + return self._html_widget + + @property + def stream_ids(self): + """ + Returns the data stream ID suffixes of the SensorType element. + + Returns: + List: The data stream ID suffixes of the SensorType element. + """ + return self._stream_ids + + def to_json(self): + """ + Returns the object in JSON format. + + Returns: + The sensor type object in JSON format. + """ + json_dict = { + "code": self._code, + "name": self._name, + "html_widget": self._html_widget, + "streams": self._stream_ids + } + return json_dict + + @staticmethod + def list_sensors(): + """ + Returns a JSON object containing all the sensor types. + + Returns: + A JSON object containing all the sensor types. + """ + return [sensor_type.to_json() for sensor_type in SensorType] diff --git a/sensor_lab/sensorlabcore/routing.py b/sensor_lab/sensorlabcore/routing.py new file mode 100644 index 0000000..b9e103b --- /dev/null +++ b/sensor_lab/sensorlabcore/routing.py @@ -0,0 +1,22 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/datapoints/', consumers.DataPointConsumer.as_asgi()), + re_path(r'wss/datapoints/', consumers.DataPointConsumer.as_asgi()), +] diff --git a/sensor_lab/sensorlabcore/templates/dashboard.html b/sensor_lab/sensorlabcore/templates/dashboard.html new file mode 100644 index 0000000..0cb9a5d --- /dev/null +++ b/sensor_lab/sensorlabcore/templates/dashboard.html @@ -0,0 +1,77 @@ +{% extends "base_logged_in.html" %} +{% load static %} +{% block title %} +Digi XBee Sensor Lab - Dashboard +{% endblock %} +{% block nav_widgets %} + + +
+{% endblock %} +{% block page_content %} +
+
+
+
+
+
Click here to add a new XBee sensor
+
+
+
+
+
+
+ Add a new XBee sensor +
+
+
Sensor name:
+ +
XBee device the sensor is attached to:
+ +
Sensor type:
+ +
+
Add sensor
+
+
+
+ +{% endblock %} +{% block javascript %} + + +{% endblock %} \ No newline at end of file diff --git a/sensor_lab/sensorlabcore/tests.py b/sensor_lab/sensorlabcore/tests.py new file mode 100644 index 0000000..805ba4c --- /dev/null +++ b/sensor_lab/sensorlabcore/tests.py @@ -0,0 +1,17 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.test import TestCase + +# Create your tests here. diff --git a/sensor_lab/sensorlabcore/urls.py b/sensor_lab/sensorlabcore/urls.py new file mode 100644 index 0000000..b571a20 --- /dev/null +++ b/sensor_lab/sensorlabcore/urls.py @@ -0,0 +1,24 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.urls import path + +from . import views + +urlpatterns = [ + path('', views.dashboard, name='index'), + path('ajax/get_sensor_types', views.get_sensor_types, name='get_sensor_types'), + path('ajax/get_devices', views.get_devices, name='get_devices'), + path('ajax/get_data_streams', views.get_data_streams, name='get_data_streams') +] diff --git a/sensor_lab/sensorlabcore/views.py b/sensor_lab/sensorlabcore/views.py new file mode 100644 index 0000000..3f49ba0 --- /dev/null +++ b/sensor_lab/sensorlabcore/views.py @@ -0,0 +1,150 @@ +# Copyright 2022, Digi International Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from sensorlabcore.drm_requests import * +from sensorlabcore.models import SensorType + +# Constants. +ID_ERROR_GUIDE = "error_guide" +ID_ERROR_MSG = "error_msg" +ID_ERROR_TITLE = "error_title" +ID_STREAMS = "data_streams" + +ERROR_MESSAGE_DATA_POINTS = "Could not read sensor value(s)." +ERROR_MESSAGE_NO_DEVICES = "Could not find any XBee Cellular device in the DRM account. " +ERROR_MESSAGE_SETUP_MODULES = "Please, verify the documentation on how to set up your XBee Cellular " \ + "devices to run the XBee Sensor Lab MicroPython applications." + +TITLE_NO_DEVICES = "No XBee Cellular devices found." + + +def dashboard(request): + if is_authenticated(request): + if request.method == "GET": + return TemplateResponse(request, 'dashboard.html', + get_request_data(request)) + else: + return redirect_login(request) + + +def get_request_data(request): + """ + Gets the request data and saves it in a dictionary to be distributed as + context variables. + + Args: + request (:class:`.WSGIRequest`): The request to get the data from. + + Returns: + dic: A dictionary containing the context variables. + """ + return {} + + +def redirect_login(request): + """ + Redirects to the login page, passing any argument if present. + + Args: + request (:class:`.WSGIRequest`): The HTTP request. + + Returns: + An `HttpResponseRedirect` to the login page. + """ + url = "/access/login" + if request.path is not None and request.GET is not None and len(request.GET) > 0: + url += "?dest={}".format(request.path.replace("/", "")) + for arg in request.GET: + url += "&{}={}".format(arg, request.GET[arg]) + return redirect(url) + + +def get_devices(request): + """ + Returns a JSON response containing the XBee Cellular devices of the DRM + account. + + Args: + request (:class:`.WSGIRequest`): The request used to check if the + user is authenticated and to generate the Device Cloud instance. + + Returns: + :class:`.JsonResponse`: A JSON response with the list of XBee Cellular + device IDs within the DRM account and their connection status. + """ + # Check if the AJAX request is valid. + error = check_ajax_request(request) + if error is not None: + return error + + cc_devices = get_cellular_devices(request) + if len(cc_devices) > 0: + return JsonResponse({ID_READ_DEVICES: [cc_device.to_json() for cc_device in cc_devices]}, + status=200) + else: + return JsonResponse({ID_ERROR_TITLE: TITLE_NO_DEVICES, + ID_ERROR_MSG: ERROR_MESSAGE_NO_DEVICES, + ID_ERROR_GUIDE: ERROR_MESSAGE_SETUP_MODULES}) + + +def get_sensor_types(request): + """ + Returns a JSON response containing all the sensor types. + + Args: + request (:class:`.WSGIRequest`): The request used to check if the + user is authenticated. + + Returns: + :class:`.JsonResponse`: A JSON response containing all the sensor + types. + """ + # Check if the AJAX request is valid. + error = check_ajax_request(request) + if error is not None: + return error + + return JsonResponse({ID_READ_SENSOR_TYPES: SensorType.list_sensors()}, + status=200) + + +def get_data_streams(request): + """ + Reads a list of data streams and returns the answer in JSON format. + + Args: + request (:class:`.WSGIRequest`): The request used to check if the + user is authenticated and to generate the Device Cloud instance. + + Returns: + :class:`.JsonResponse`: A JSON response with the operation result. + """ + # Check if the AJAX request is valid. + error = check_ajax_request(request) + if error is not None: + return error + + # Get the data stream IDs from the POST request. + data = json.loads(request.body.decode(request.encoding)) + data_streams = data[ID_STREAMS] + answer = get_data_points(request, data_streams) + + if answer is not None: + if ID_ERROR in answer: + return JsonResponse({ID_ERROR: answer[ID_ERROR]}, status=400) + return JsonResponse(answer, status=200) + return JsonResponse({ID_ERROR: ERROR_MESSAGE_DATA_POINTS}, status=400) diff --git a/sensor_lab/static_files/css/general.css b/sensor_lab/static_files/css/general.css new file mode 100644 index 0000000..cca72d8 --- /dev/null +++ b/sensor_lab/static_files/css/general.css @@ -0,0 +1,576 @@ +/* GENERAL */ +:root { + --digi-green: #84C361; + --digi-green-transparent: rgba(132, 195, 97, 0.5); + --digi-green-dark: #6E9C54; + --digi-gray: #717174; + --digi-light-gray: #E8E8E8; + --digi-gray-transparent: rgba(113, 113, 116, 0.3); + --white-transparent: rgba(255, 255, 255, 0.5); + --nav-img-size: 34px; + + --font-family-open-sans: "Open Sans", Arial, "sans serif"; +} + +@font-face { + font-family: 'Open Sans'; + src: local('Open Sans'), + url('../fonts/OpenSans-Regular.ttf') format('truetype'); +} + +html, body { + overflow: hidden; +} + +body { + font-family: var(--font-family-open-sans); +} + +.disabled-div { + pointer-events: none; + opacity: 0.4; +} + +.btn-primary { + background-color: var(--digi-green); + border: none; + color: white; + padding: 12px 80px; + text-align: center; + text-decoration: none; + display: inline-block; + text-transform: uppercase; + font-size: 14px; + -webkit-box-shadow: 0 10px 30px 0 rgba(132, 195, 97, 0.4); + box-shadow: 0 10px 30px 0 rgba(132, 195, 97, 0.4); + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; + -webkit-transition: all 0.3s ease-in-out; + -moz-transition: all 0.3s ease-in-out; + -ms-transition: all 0.3s ease-in-out; + -o-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + visibility: inherit; +} + +.btn-primary:hover, .btn-primary:active { + background-color: var(--digi-green) !important; + box-shadow: 0 0 0 .2rem var(--digi-green-transparent) !important; + color: white !important; +} + +.btn-primary:focus { + box-shadow: 0 0 0 .2rem var(--digi-green-transparent) !important; +} + +.btn-primary:disabled { + background-color: var(--digi-light-gray) !important; +} + +.element-grayed { + opacity: 20%; + background-color: white; + pointer-events: none; +} + +/* NAVIGATION BAR */ +.banner-icon { + height: var(--nav-img-size); +} + +#banner-text { + font-size: 20px; + line-height: 14px; + height: 14px; + margin: 0px; + color: var(--digi-gray); +} + +.navbar-brand { + padding-top: 2px; + padding-bottom: 2px; +} + +.navbar-button { + margin-left: 12px; +} + +#topBar { + z-index: 1000; +} + +.dropdown-toggle, .widget-button { + background-color: transparent; + border: 0px solid transparent; + padding: 0px; +} + +.dropdown-toggle:focus, .widget-button:focus { + outline: 0; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +.dropdown-toggle::after { + vertical-align: 0px; +} + +.dropdown-menu { + right: 0; + left: auto; + margin: 12px 0px; + border: 0px solid transparent; + border-radius: 0px; + padding: 2px 0px; +} + +.digi-gray .dropdown-toggle::after { + color: var(--digi-gray) +} + +.digi-green .dropdown-toggle::after { + color: var(--digi-green) +} + +.dropdown-item { + color: var(--digi-gray); +} + +.dropdown-item:hover { + background-color: var(--digi-gray-transparent); + color: var(--digi-gray); +} + +@media (min-width: 992px) { + .navbar-expand-lg > .nav-container { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } +} +.navbar > .nav-container { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: justify; + justify-content: space-between; + width: 100%; + padding-right: 30px; + padding-left: 30px; + margin-right: auto; + margin-left: auto; +} + +.nav-right-container { + display: flex; +} + +.vertical-separator { + border-left: 2px solid var(--digi-gray); + height: var(--nav-img-size); + margin-left: 20px; + margin-right: 20px; +} + +#device-name { + height: var(--nav-img-size); + font-size: 20px; + color: var(--digi-gray); + font-weight: bold; + padding-top: 4px; + margin: 0px 10px; +} + +.device-title-img { + padding-top: 6px; + width: 24px; +} +/* END NAVIGATION BAR */ + +/* CENTRAL CONTAINER */ +#body-row { + margin-left:0; + margin-right:0; + height: calc( 100vh - var(--nav-img-size) - 20px ); /* height of the top toolbar or banner plus 20 px of padding */ + display: flex; +} + +.container-fluid { + padding: 0px; + overflow: auto; + font-size: 16px; +} + +.row { + position: absolute; + --bs-gutter-x: 0px; + width: 100%; + max-width: 1450px; + padding: 10px; + left: 50%; + transform: translateX(-50%); + margin: 0px; +} +/* END CENTRAL CONTAINER */ + +/* LOADING */ +.loading-wrapper { + position: relative; + height: 100%; + z-index: 1; + background: +} +/* END LOADING */ + +/* POPUP */ +.popup { + z-index: 999; + position: absolute; + transform: translate(-50%, -50%); + background-color: white; + padding: 20px 10px; + border-radius: 15px; + top: 50%; + left: 50%; + text-align: center; + max-width: 800px; +} + +.popup i { + font-size: 1.3vw; +} + +.popup .popup-item { + width: 70%; +} + +.popup .popup-title, .popup .popup-text { + color: var(--digi-gray); +} + +.popup .popup-title { + font-size: 18px; + font-weight: bold; + margin-bottom: 15px; +} + +.popup-error-reconnect { + top: 35%; + z-index: 999; + padding: 20px 30px; +} +/* END POPUP */ + +/* ADD SENSOR DIALOG */ +.add-sensor-dialog { + position: absolute; + width: 100%; + height: 100%; + z-index: 999; + top: 0px; + visibility: hidden; + background: rgba(255, 255, 255, 0.7); +} + +.add-sensor-header { + background-color: var(--digi-gray); + color: white; + padding: 5px 10px; + align-items: center; + font-size: 16px; +} + +.add-sensor-header-button { + margin-top: 4px; + float: right; + cursor: pointer; +} + +.add-sensor-header-button:hover { + color: var(--digi-green); +} + +.add-sensor-container { + background-color: white; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 70%; + min-height: 200px; + max-width: 500px; + -webkit-box-shadow: 0 10px 30px 0 rgb(0 0 0 / 40%); + box-shadow: 0 10px 30px 0 rgb(0 0 0 / 40%); +} + +.add-sensor-label { + position: relative; + padding-bottom: 10px; + padding-top: 10px; + padding-right: 10px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + left: 7.5%; + width: 85%; +} + +.add-sensor-input { + position: relative; + padding: 2px 5px !important; + text-align: left !important; + left: 7.5%; + font-size: 16px !important; + margin-bottom: 15px; + -webkit-transition: none !important; + -moz-transition: none !important; + -ms-transition: none !important; + -o-transition: none !important; + transition: none !important; +} + +.add-sensor-error { + color: red; + font-size: 12px; + font-style: italic; + margin-bottom: 5px; + padding-right: 10px; + position: relative; + left: 7.5%; + width: 85%; +} + +.add-sensor-button { + height: 35px; + width: 50%; + color: white; + background-color: var(--digi-gray); + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + bottom: 0px; + margin: 0 auto; + margin-bottom: 15px; + cursor: pointer; +} + +.add-sensor-button:hover { + background-color: var(--digi-green); +} + +.add-sensor-button-disabled { + cursor: default; + color: white; + background-color: #c3c3c3 !important; + pointer-events: none; +} + +.add-sensor-field-error { + background-color: #f3b1b1 !important; +} +/* END ADD SENSOR DIALOG */ + +/* XBEE SENSOR */ +.xbee-sensor-box { + position: relative; + padding: 10px; +} + +.xbee-sensor { + position: relative; + padding: 5px; + background-color: white; + border-radius: 10px 10px 10px 10px; + -webkit-box-shadow: 0 5px 15px 0 rgb(0 0 0 / 30%); + box-shadow: 0 5px 15px 0 rgb(0 0 0 / 30%); + height: 300px; +} + +.xbee-sensor-prompt { + border: 5px dashed var(--digi-gray); + background: transparent; + cursor: pointer; + stroke-dasharray = 6 14; +} + +.xbee-sensor-prompt:hover { + opacity: 0.5; +} + +.xbee-sensor-prompt-image { + width: 100%; + font-size: 80px; + color: var(--digi-gray); + text-align: center; + padding: 15px; +} + +.xbee-sensor-prompt-text { + width: 100%; + font-size: 32px; + color: var(--digi-gray); + text-align: center; + padding: 15px; +} + +.xbee-sensor-loading { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + text-align: center; + font-size: 24px; + color: var(--digi-gray); + padding-top: 20px; + background: rgb(255, 255, 255, 0.9); + border-radius: 10px 10px 10px 10px; +} + +.xbee-sensor-loading-hide { + display: none; +} + +.xbee-sensor-header { + position: relative; + padding-bottom: 2px; + border-bottom: 2px solid var(--digi-green); + height: 10%; +} + +.xbee-sensor-title { + color: var(--digi-gray); + font-size: 16px; + font-weight: bold; + text-align: center; + padding: 0px 15px; +} + +.xbee-sensor-button { + color: var(--digi-gray); + position: absolute; + top: 3px; + cursor: pointer; + font-size: 18px; +} + +.xbee-sensor-button:hover { + color: var(--digi-green); +} + +.xbee-sensor-button-left { + margin-left: 8px; + float: left; +} + +.xbee-sensor-button-right { + right: 10px; +} + +.xbee-sensor-widget { + position: relative; + padding: 10px; + height: 90%; +} + +.sensor-widget-image { + text-align: center; +} + +.sensor-widget-image img { + max-width: 100%; + max-height: 100%; +} + +.sensor-widget-value { + position: relative; + height: 100%; + text-align: right; + color: var(--digi-gray); +} + +.sensor-widget-value-green { + color: var(--digi-green); +} + +.sensor-widget-value span { + line-height: 100%; + position: absolute; + right: 5px; + bottom: 0px; +} + +.sensor-widget-units { + position: relative; + height: 100%; + text-align: left; + color: var(--digi-gray); +} + +.sensor-widget-units span { + line-height: 100%; + position: absolute; + left: 0; + bottom: 0px; +} + +.sensor-widget-h { + height: 100%; + position: relative; + display: flex; +} + +.sensor-widget-image-h { + width: 40%; +} + +.sensor-widget-value-h { + width: 50%; +} + +.sensor-widget-value-h span { + bottom: 10px; +} + +.sensor-widget-units-h { + width: 10%; +} + +.sensor-widget-units-h span { + bottom: 13px; +} + +.sensor-widget-v { + height: 100%; + position: relative; +} + +.sensor-widget-image-v { + height: 60%; +} + +.sensor-widget-data-v { + height: 40%; + display: flex; +} + +.sensor-widget-value-v { + width: 75%; +} + +.sensor-widget-value-v span { + bottom: 0px; +} + +.sensor-widget-units-v { + width: 25%; +} + +.sensor-widget-units-v span { + bottom: 3px; +} +/* END XBEE SENSOR */ diff --git a/sensor_lab/static_files/css/toastr.css b/sensor_lab/static_files/css/toastr.css new file mode 100644 index 0000000..65c9fea --- /dev/null +++ b/sensor_lab/static_files/css/toastr.css @@ -0,0 +1,228 @@ +.toast-title { + font-weight: bold; +} +.toast-message { + -ms-word-wrap: break-word; + word-wrap: break-word; +} +.toast-message a, +.toast-message label { + color: #FFFFFF; +} +.toast-message a:hover { + color: #CCCCCC; + text-decoration: none; +} +.toast-close-button { + position: relative; + right: -0.3em; + top: -0.3em; + float: right; + font-size: 20px; + font-weight: bold; + color: #FFFFFF; + -webkit-text-shadow: 0 1px 0 #ffffff; + text-shadow: 0 1px 0 #ffffff; + opacity: 0.8; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=80); + filter: alpha(opacity=80); + line-height: 1; +} +.toast-close-button:hover, +.toast-close-button:focus { + color: #000000; + text-decoration: none; + cursor: pointer; + opacity: 0.4; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40); + filter: alpha(opacity=40); +} +.rtl .toast-close-button { + left: -0.3em; + float: left; + right: 0.3em; +} +/*Additional properties for button version + iOS requires the button element instead of an anchor tag. + If you want the anchor version, it requires `href="#"`.*/ +button.toast-close-button { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; +} +.toast-top-center { + top: 0; + right: 0; + width: 100%; +} +.toast-bottom-center { + bottom: 0; + right: 0; + width: 100%; +} +.toast-top-full-width { + top: 0; + right: 0; + width: 100%; +} +.toast-bottom-full-width { + bottom: 0; + right: 0; + width: 100%; +} +.toast-top-left { + top: 12px; + left: 12px; +} +.toast-top-right { + top: 12px; + right: 12px; +} +.toast-bottom-right { + right: 12px; + bottom: 12px; +} +.toast-bottom-left { + bottom: 12px; + left: 12px; +} +#toast-container { + position: fixed; + z-index: 999999; + pointer-events: none; + /*overrides*/ +} +#toast-container * { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +#toast-container > div { + position: relative; + pointer-events: auto; + overflow: hidden; + margin: 0 0 6px; + padding: 15px 15px 15px 50px; + width: 300px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; + background-position: 15px center; + background-repeat: no-repeat; + -moz-box-shadow: 0 0 12px #999999; + -webkit-box-shadow: 0 0 12px #999999; + box-shadow: 0 0 12px #999999; + color: #FFFFFF; + opacity: 0.8; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=80); + filter: alpha(opacity=80); +} +#toast-container > div.rtl { + direction: rtl; + padding: 15px 50px 15px 15px; + background-position: right 15px center; +} +#toast-container > div:hover { + -moz-box-shadow: 0 0 12px #000000; + -webkit-box-shadow: 0 0 12px #000000; + box-shadow: 0 0 12px #000000; + opacity: 1; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100); + filter: alpha(opacity=100); + cursor: pointer; +} +#toast-container > .toast-info { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important; +} +#toast-container > .toast-error { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important; +} +#toast-container > .toast-success { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important; +} +#toast-container > .toast-warning { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important; +} +#toast-container.toast-top-center > div, +#toast-container.toast-bottom-center > div { + width: 300px; + margin-left: auto; + margin-right: auto; +} +#toast-container.toast-top-full-width > div, +#toast-container.toast-bottom-full-width > div { + width: 96%; + margin-left: auto; + margin-right: auto; +} +.toast { + background-color: #030303; +} +.toast-success { + background-color: #51A351; +} +.toast-error { + background-color: #BD362F; +} +.toast-info { + background-color: #2F96B4; +} +.toast-warning { + background-color: #F89406; +} +.toast-progress { + position: absolute; + left: 0; + bottom: 0; + height: 4px; + background-color: #000000; + opacity: 0.4; + -ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40); + filter: alpha(opacity=40); +} +/*Responsive Design*/ +@media all and (max-width: 240px) { + #toast-container > div { + padding: 8px 8px 8px 50px; + width: 11em; + } + #toast-container > div.rtl { + padding: 8px 50px 8px 8px; + } + #toast-container .toast-close-button { + right: -0.2em; + top: -0.2em; + } + #toast-container .rtl .toast-close-button { + left: -0.2em; + right: 0.2em; + } +} +@media all and (min-width: 241px) and (max-width: 480px) { + #toast-container > div { + padding: 8px 8px 8px 50px; + width: 18em; + } + #toast-container > div.rtl { + padding: 8px 50px 8px 8px; + } + #toast-container .toast-close-button { + right: -0.2em; + top: -0.2em; + } + #toast-container .rtl .toast-close-button { + left: -0.2em; + right: 0.2em; + } +} +@media all and (min-width: 481px) and (max-width: 768px) { + #toast-container > div { + padding: 15px 15px 15px 50px; + width: 25em; + } + #toast-container > div.rtl { + padding: 15px 50px 15px 15px; + } +} \ No newline at end of file diff --git a/sensor_lab/static_files/fonts/OpenSans-Bold.ttf b/sensor_lab/static_files/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..fd79d43 Binary files /dev/null and b/sensor_lab/static_files/fonts/OpenSans-Bold.ttf differ diff --git a/sensor_lab/static_files/fonts/OpenSans-BoldItalic.ttf b/sensor_lab/static_files/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 0000000..9bc8009 Binary files /dev/null and b/sensor_lab/static_files/fonts/OpenSans-BoldItalic.ttf differ diff --git a/sensor_lab/static_files/fonts/OpenSans-Italic.ttf b/sensor_lab/static_files/fonts/OpenSans-Italic.ttf new file mode 100644 index 0000000..c90da48 Binary files /dev/null and b/sensor_lab/static_files/fonts/OpenSans-Italic.ttf differ diff --git a/sensor_lab/static_files/fonts/OpenSans-Regular.ttf b/sensor_lab/static_files/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..db43334 Binary files /dev/null and b/sensor_lab/static_files/fonts/OpenSans-Regular.ttf differ diff --git a/sensor_lab/static_files/images/Digi_logo_banner.png b/sensor_lab/static_files/images/Digi_logo_banner.png new file mode 100644 index 0000000..7ae76e0 Binary files /dev/null and b/sensor_lab/static_files/images/Digi_logo_banner.png differ diff --git a/sensor_lab/static_files/images/Digi_logo_banner_gray.png b/sensor_lab/static_files/images/Digi_logo_banner_gray.png new file mode 100644 index 0000000..1e5289f Binary files /dev/null and b/sensor_lab/static_files/images/Digi_logo_banner_gray.png differ diff --git a/sensor_lab/static_files/images/add_gray.png b/sensor_lab/static_files/images/add_gray.png new file mode 100644 index 0000000..2f4a522 Binary files /dev/null and b/sensor_lab/static_files/images/add_gray.png differ diff --git a/sensor_lab/static_files/images/add_green.png b/sensor_lab/static_files/images/add_green.png new file mode 100644 index 0000000..dcf0e31 Binary files /dev/null and b/sensor_lab/static_files/images/add_green.png differ diff --git a/sensor_lab/static_files/images/combo_arrow_gray.png b/sensor_lab/static_files/images/combo_arrow_gray.png new file mode 100644 index 0000000..0ec3be2 Binary files /dev/null and b/sensor_lab/static_files/images/combo_arrow_gray.png differ diff --git a/sensor_lab/static_files/images/combo_arrow_green.png b/sensor_lab/static_files/images/combo_arrow_green.png new file mode 100644 index 0000000..e11e9bd Binary files /dev/null and b/sensor_lab/static_files/images/combo_arrow_green.png differ diff --git a/sensor_lab/static_files/images/loading.gif b/sensor_lab/static_files/images/loading.gif new file mode 100644 index 0000000..a9d6788 Binary files /dev/null and b/sensor_lab/static_files/images/loading.gif differ diff --git a/sensor_lab/static_files/images/profile_gray.png b/sensor_lab/static_files/images/profile_gray.png new file mode 100644 index 0000000..a4f2653 Binary files /dev/null and b/sensor_lab/static_files/images/profile_gray.png differ diff --git a/sensor_lab/static_files/images/profile_green.png b/sensor_lab/static_files/images/profile_green.png new file mode 100644 index 0000000..7a6654f Binary files /dev/null and b/sensor_lab/static_files/images/profile_green.png differ diff --git a/sensor_lab/static_files/images/refresh_gray.png b/sensor_lab/static_files/images/refresh_gray.png new file mode 100644 index 0000000..571ac87 Binary files /dev/null and b/sensor_lab/static_files/images/refresh_gray.png differ diff --git a/sensor_lab/static_files/images/refresh_green.png b/sensor_lab/static_files/images/refresh_green.png new file mode 100644 index 0000000..dfcdae6 Binary files /dev/null and b/sensor_lab/static_files/images/refresh_green.png differ diff --git a/sensor_lab/static_files/images/widgets/humidity.png b/sensor_lab/static_files/images/widgets/humidity.png new file mode 100644 index 0000000..e9ecd5b Binary files /dev/null and b/sensor_lab/static_files/images/widgets/humidity.png differ diff --git a/sensor_lab/static_files/images/widgets/scale.png b/sensor_lab/static_files/images/widgets/scale.png new file mode 100644 index 0000000..ba34bd3 Binary files /dev/null and b/sensor_lab/static_files/images/widgets/scale.png differ diff --git a/sensor_lab/static_files/images/widgets/temperature.png b/sensor_lab/static_files/images/widgets/temperature.png new file mode 100644 index 0000000..b1f3d9b Binary files /dev/null and b/sensor_lab/static_files/images/widgets/temperature.png differ diff --git a/sensor_lab/static_files/js/add-sensor.js b/sensor_lab/static_files/js/add-sensor.js new file mode 100644 index 0000000..f88c574 --- /dev/null +++ b/sensor_lab/static_files/js/add-sensor.js @@ -0,0 +1,624 @@ +/* + * Copyright 2022, Digi International Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// Constants. +const CLASS_ADD_SENSOR_BUTTON_DISABLED = "add-sensor-button-disabled"; +const CLASS_ADD_SENSOR_FIELD_ERROR = "add-sensor-field-error"; +const CLASS_ADD_SENSOR_STATUS_ONLINE = "add-sensor-device-online"; +const CLASS_ADD_SENSOR_STATUS_OFFLINE = "add-sensor-device-offline"; +const CLASS_SENSOR_DATA_GREEN = "sensor-widget-value-green"; +const CLASS_SENSOR_LOADING_PANEL = "xbee-sensor-loading"; +const CLASS_SENSOR_LOADING_HIDE = "xbee-sensor-loading-hide"; +const CLASS_SENSOR_ID_REFRESH_BUTTON = "fa-sync-alt"; + +const ERROR_ADD_NULL_SENSOR_DEVICE = "Could not add sensor, the XBee Cellular device of sensor was not defined."; +const ERROR_ADD_NULL_SENSOR_NAME = "Could not add sensor, sensor name was not defined."; +const ERROR_ADD_NULL_SENSOR_TYPE = "Could not add sensor, sensor type was not defined."; +const ERROR_NAME_EMPTY = "You must specify a name for the sensor."; +const ERROR_DEVICE_EMPTY = "You must select an XBee Cellular device from the list."; +const ERROR_TYPE_EMPTY = "You must specify the type of sensor."; + +const FONT_FIT_FACTOR_UNITS_H = 0.1; // Obtained through empirical tests. +const FONT_FIT_FACTOR_VALUE_H = 0.22; // Obtained through empirical tests. +const FONT_FIT_FACTOR_UNITS_V = 0.15; // Obtained through empirical tests. +const FONT_FIT_FACTOR_VALUE_V = 0.3; // Obtained through empirical tests. + +const ID_ADD_SENSOR_BUTTON = "add_sensor_button"; +const ID_ADD_SENSOR_BUTTON_DIALOG = "add_sensor_button_dialog"; +const ID_ADD_SENSOR_DEVICE = "add_sensor_device"; +const ID_ADD_SENSOR_DIALOG = "add_sensor_dialog"; +const ID_ADD_SENSOR_ERROR = "add_sensor_error"; +const ID_ADD_SENSOR_NAME = "add_sensor_name"; +const ID_ADD_SENSOR_TYPE = "add_sensor_type"; +const ID_DASHBOARD = "dashboard"; +const ID_DATA_STREAMS = "data_streams"; +const ID_REFRESH_BUTTON = "refresh_sensors_button"; +const ID_READ_DEVICES = "read_devices"; +const ID_READ_SENSOR_TYPES = "read_sensor_types"; +const ID_SENSOR_CODE = "sensor_code"; +const ID_SENSOR_DEVICE = "sensor_device"; +const ID_SENSOR_NAME = "sensor_code"; +const ID_STREAM = "stream"; +const ID_TYPE = "type"; +const ID_VALUE = "value"; +const ID_XBEE_SENSOR_PROMPT = "xbee_sensor_prompt"; + +const MESSAGE_ADDING_SENSOR = "Adding sensor..."; +const MESSAGE_LOADING_DATA = "Loading data..."; + +const TEMPLATE_SENSOR = "" + + "
" + + "
" + + "
" + + "
" + + "
{1}
" + + "
" + + "
" + + "
" + + " {2}" + + "
" + + "
" + + " Loading..." + + " " + + "
" + + "
"; +const TEMPLATE_SENSOR_ID = "{0}-{1}"; + +const XBEE_SENSOR_CLASSES = ["col-sm-6", + "col-md-6", + "col-lg-4", + "col-xl-3", + "xbee-sensor-box"]; + +// Variables. +var dialogDevicesLoaded = false; +var dialogSensorTypesLoaded = false; + +var sensorDevice; +var sensorName; +var sensorType; +var sensorTypeList; + +var sensorsList = new Map(); + +var dataPointsSocket; + +// Shows/hides the "Add sensor" dialog. +function showAddSensorDialog(visible) { + // Initialize variables. + var addSensorDialogElement = document.getElementById(ID_ADD_SENSOR_DIALOG); + var addSensorErrorElement = document.getElementById(ID_ADD_SENSOR_ERROR); + var addSensorButtonDialogElement = document.getElementById(ID_ADD_SENSOR_BUTTON_DIALOG); + // Enable/Disable the refresh button of the toolbar. + document.getElementById(ID_REFRESH_BUTTON).disabled = visible; + // Enable/Disable the 'Add sensor' button of the toolbar. + document.getElementById(ID_ADD_SENSOR_BUTTON).disabled = visible; + // Initialize error label. + addSensorErrorElement.innerHTML = " "; + addSensorErrorElement.style.visibility = "hidden"; + // Disable the 'Add sensor' button of the dialog. + if (!addSensorButtonDialogElement.classList.contains(CLASS_ADD_SENSOR_BUTTON_DISABLED)) + addSensorButtonDialogElement.classList.add(CLASS_ADD_SENSOR_BUTTON_DISABLED); + // Apply visible state to the dialog. + if (visible) { + addSensorDialogElement.style.visibility = "visible"; + resetDialog(); + loadDialogData(); + } else { + addSensorDialogElement.style.visibility = "hidden"; + } +} + +// Returns whether the 'Add sensor' dialog is open or not. +function isSensorDialogShowing() { + // Initialize variables. + var addSensorDialogElement = document.getElementById(ID_ADD_SENSOR_DIALOG); + if (addSensorDialogElement == null) + return false + // Return whether the dialog is showing or not. + return addSensorDialogElement.style.visibility == "visible"; +} + +// Closes the 'Add sensor' dialog. +function closeSensorDialog() { + showAddSensorDialog(false); +} + +// Loads the dialog data. +function loadDialogData() { + // Reset dialog data loaded var. + dialogDevicesLoaded = false; + dialogSensorTypesLoaded = false; + // Show loading dialog. + showLoadingPopup(true, MESSAGE_LOADING_DATA); + listDevices(); + listSensorTypes(); +} + +// Lists XBee Cellular devices from DRM. +function listDevices() { + // Send the request. + $.post( + "../ajax/get_devices", + function(data) { + // Hide the loading panel. + if (dialogSensorTypesLoaded) + showLoadingPopup(false); + dialogDevicesLoaded = true; + // Process answer. + processListDevicesAnswer(data); + // Validate the dialog. + validateDialog(); + } + ).fail(function(response) { + // Hide the loading panel. + if (dialogSensorTypesLoaded) + showLoadingPopup(false); + dialogDevicesLoaded = true; + // Process error. + processAjaxErrorResponse(response); + // Validate the dialog. + validateDialog(); + }); +} + +// Processes the answer of the list devices request. +function processListDevicesAnswer(response) { + // Check if there was any error in the request. + if (checkErrorResponse(response, false)) { + // Do not continue. + return; + } + // Get the devices from the JSON response. + let readDevices = response[ID_READ_DEVICES]; + // Check if the list of devices contains any device. + if (readDevices == null || readDevices.length == 0) { + return; + } + // Process devices. + var sensorDeviceElement = document.getElementById(ID_ADD_SENSOR_DEVICE); + for (let device of readDevices) { + // Add sensor type to the proper combo box. + var opt = document.createElement('option'); + if (device.online) + opt.classList.add(CLASS_ADD_SENSOR_STATUS_OFFLINE); + else + opt.classList.add(CLASS_ADD_SENSOR_STATUS_OFFLINE); + opt.value = device.device_id; + opt.innerHTML = device.device_id; + sensorDeviceElement.appendChild(opt); + } +} + +// Lists XBee sensor types. +function listSensorTypes() { + // Send the request. + $.post( + "../ajax/get_sensor_types", + function(data) { + // Hide the loading panel. + if (dialogDevicesLoaded) + showLoadingPopup(false); + dialogSensorTypesLoaded = true; + // Process answer. + processListSensorTypesAnswer(data); + // Validate the dialog. + validateDialog(); + } + ).fail(function(response) { + // Hide the loading panel. + if (dialogDevicesLoaded) + showLoadingPopup(false); + dialogSensorTypesLoaded = true; + // Process error. + processAjaxErrorResponse(response); + // Validate the dialog. + validateDialog(); + }); +} + +// Processes the answer of the list devices request. +function processListSensorTypesAnswer(response) { + // Check if there was any error in the request. + if (checkErrorResponse(response, false)) { + // Do not continue. + return; + } + // Get the sensor types from the JSON response. + let readSensorTypes = response[ID_READ_SENSOR_TYPES]; + // Check if the list of sensor types contains any sensor. + if (readSensorTypes == null || readSensorTypes.length == 0) { + return; + } + // Save the list of sensors. + sensorTypeList = readSensorTypes; + // Process sensor types. + var sensorTypeElement = document.getElementById(ID_ADD_SENSOR_TYPE); + for (let sensor of readSensorTypes) { + // Add sensor type to the proper combo box. + var opt = document.createElement('option'); + opt.value = sensor.code; + opt.innerHTML = sensor.name; + sensorTypeElement.appendChild(opt); + } +} + +// Validates the 'Add sensor' dialog. +function validateDialog() { + // Initialize variables. + var error = null; + var sensorNameElement = document.getElementById(ID_ADD_SENSOR_NAME); + var sensorDeviceElement = document.getElementById(ID_ADD_SENSOR_DEVICE); + var sensorTypeElement = document.getElementById(ID_ADD_SENSOR_TYPE); + var addSensorErrorElement = document.getElementById(ID_ADD_SENSOR_ERROR); + var addSensorButtonElement = document.getElementById(ID_ADD_SENSOR_BUTTON_DIALOG); + // Check name field. + if (sensorNameElement.value == null || sensorNameElement.value.length == 0) { + if (!sensorNameElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorNameElement.classList.add(CLASS_ADD_SENSOR_FIELD_ERROR); + error = ERROR_NAME_EMPTY; + } else if (sensorNameElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) { + sensorNameElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + } + // Check device field. + if (sensorDeviceElement.value == null) { + if (!sensorDeviceElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorDeviceElement.classList.add(CLASS_ADD_SENSOR_FIELD_ERROR); + error = ERROR_DEVICE_EMPTY; + } else if (sensorDeviceElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) { + sensorDeviceElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + } + // Check sensor type field. + if (sensorTypeElement.value == null) { + if (!sensorTypeElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorTypeElement.classList.add(CLASS_ADD_SENSOR_FIELD_ERROR); + error = ERROR_TYPE_EMPTY; + } else if (sensorTypeElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) { + sensorTypeElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + } + // Update controls. + if (error == null) { + addSensorErrorElement.innerHTML = " "; + addSensorErrorElement.style.visibility = "hidden"; + if (addSensorButtonElement.classList.contains(CLASS_ADD_SENSOR_BUTTON_DISABLED)) + addSensorButtonElement.classList.remove(CLASS_ADD_SENSOR_BUTTON_DISABLED); + } else { + addSensorErrorElement.innerHTML = error; + addSensorErrorElement.style.visibility = "visible"; + if (!addSensorButtonElement.classList.contains(CLASS_ADD_SENSOR_BUTTON_DISABLED)) + addSensorButtonElement.classList.add(CLASS_ADD_SENSOR_BUTTON_DISABLED); + } + // Configure sensor parameters. + sensorName = sensorNameElement.value; + sensorDevice = sensorDeviceElement.value; + sensorType = getSensor(sensorTypeElement.value); +} + +// Resets the 'Add sensor' dialog. +function resetDialog() { + // Reset sensor parameters. + sensorName = null; + sensorDevice = null; + sensorType = null; + // Get dialog elements. + var sensorNameElement = document.getElementById(ID_ADD_SENSOR_NAME); + var sensorDeviceElement = document.getElementById(ID_ADD_SENSOR_DEVICE); + var sensorTypeElement = document.getElementById(ID_ADD_SENSOR_TYPE); + var addSensorErrorElement = document.getElementById(ID_ADD_SENSOR_ERROR); + // Reset styles. + if (sensorNameElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorNameElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + if (sensorDeviceElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorDeviceElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + if (sensorTypeElement.classList.contains(CLASS_ADD_SENSOR_FIELD_ERROR)) + sensorTypeElement.classList.remove(CLASS_ADD_SENSOR_FIELD_ERROR); + // Reset error. + addSensorErrorElement.innerHTML = " "; + addSensorErrorElement.style.visibility = "hidden"; + // Reset elements. + sensorNameElement.value = ""; + var i, L = sensorDeviceElement.options.length - 1; + for (i = L; i >= 0; i--) + sensorDeviceElement.remove(i); + var i, L = sensorTypeElement.options.length - 1; + for (i = L; i >= 0; i--) + sensorTypeElement.remove(i); +} + +// Adds a sensor to the dashboard. +function addSensor() { + // Display the loading popup. + showLoadingPopup(true, MESSAGE_ADDING_SENSOR); + closeSensorDialog(); + // Verify that all sensor parameters are set. + var errorMessage = null; + if (sensorName == null) + errorMessage = ERROR_ADD_NULL_SENSOR_NAME; + else if (sensorDevice == null) + errorMessage = ERROR_ADD_NULL_SENSOR_DEVICE; + else if (sensorType == null) + errorMessage = ERROR_ADD_NULL_SENSOR_TYPE; + if (errorMessage != null) { + toastr.error(errorMessage); + showLoadingPopup(false); + return; + } + // Generate an ID for the sensor. + var sensorID = TEMPLATE_SENSOR_ID.format(sensorDevice, sensorType.code); + // Generate the sensor div element. + var newSensorDiv = document.createElement("div"); + newSensorDiv.id = sensorID; + // Add the sensor classes to the new div. + for (let sensorClass of XBEE_SENSOR_CLASSES) + newSensorDiv.classList.add(sensorClass); + // Get the specific widget code. + var widgetCode = sensorType.html_widget.format(sensorDevice); + newSensorDiv.innerHTML = TEMPLATE_SENSOR.format(sensorID, sensorName, widgetCode); + // Add the sensor element to the dashboard. + var dashboardElement = document.getElementById(ID_DASHBOARD); + var xbeeSensorPromptElement = document.getElementById(ID_XBEE_SENSOR_PROMPT); + xbeeSensorPromptElement.before(newSensorDiv) + // Save sensor data. + sensorsList.set(sensorID, {[ID_SENSOR_NAME]: sensorName, + [ID_SENSOR_CODE]: sensorType.code, + [ID_SENSOR_DEVICE]: sensorDevice, + [ID_DATA_STREAMS]: sensorType.streams.map(ds => "{0}/{1}".format(sensorDevice, ds))}) + // Apply font fit factor to values and units. + applyFontFitToSensors(); + // Close the loading popup. + showLoadingPopup(false); + // Refresh sensor values. + refreshSensor(sensorID); + // Refresh DataPoints monitor. + refreshDataPointsMonitor(); +} + +// Returns the XBee sensor corresponding to the provided code. +function getSensor(sensorCode) { + if (sensorTypeList == null || sensorTypeList.length === 0) + return null; + for (let sensor of sensorTypeList) { + if (sensor.code.toString() === sensorCode) + return sensor; + } + return null; +} + +// Applies the font fit mechanism to all sensors. +function applyFontFitToSensors() { + $(".sensor-widget-value-h").map(function() { + jQuery(this).fitText(FONT_FIT_FACTOR_VALUE_H); + }); + $(".sensor-widget-value-v").map(function() { + jQuery(this).fitText(FONT_FIT_FACTOR_VALUE_V); + }); + $(".sensor-widget-units-h").map(function() { + jQuery(this).fitText(FONT_FIT_FACTOR_UNITS_H); + }) + $(".sensor-widget-units-v").map(function() { + jQuery(this).fitText(FONT_FIT_FACTOR_UNITS_V); + }) +} + +// Removes the sensor with the given ID from the dashboard. +function closeSensor(sensorID) { + var dashboardElement = document.getElementById(ID_DASHBOARD); + if (dashboardElement == null) + return; + var sensorElement = document.getElementById(sensorID); + if (sensorElement != null) + dashboardElement.removeChild(sensorElement); + sensorsList.delete(sensorID); + // Refresh DataPoints monitor. + refreshDataPointsMonitor(); +} + +// Refreshes the values of the sensor with the given ID. +function refreshSensor(sensorID) { + // Disable the refresh all sensors button. + enableRefreshAllSensorsButton(false); + // Show the loading panel of the sensor. + showSensorLoadingPopup(sensorID, true); + // Send the request. + var dataStreams = getSensorStreams(sensorID); + $.post( + "../ajax/get_data_streams", + JSON.stringify({ + [ID_DATA_STREAMS]: dataStreams + }), + function(data) { + // Hide the loading panel. + showSensorLoadingPopup(sensorID, false); + // Enable the refresh all sensors button. + if (!isSensorDialogShowing()) + enableRefreshAllSensorsButton(true); + // Process answer. + processDataStreamsAnswer(data); + } + ).fail(function(response) { + // Hide the loading panel. + showSensorLoadingPopup(sensorID, false); + // Enable the refresh all sensors button. + if (!isSensorDialogShowing()) + enableRefreshAllSensorsButton(true); + // Process error. + processAjaxErrorResponse(response); + }); +} + +// Refreshes the values of all the sensors. +function refreshSensors() { + var sensorIDs = Array.from(sensorsList.keys()); + var dataStreams = []; + // Disable the refresh button from all sensors. + for (sensorID of sensorIDs) { + dataStreams.push.apply(dataStreams, sensorsList.get(sensorID).data_streams.map(st => st)); + showSensorLoadingPopup(sensorID, true); + enableRefreshSensorButton(sensorID, false); + } + // Disable the refresh all sensors button. + enableRefreshAllSensorsButton(false); + $.post( + "../ajax/get_data_streams", + JSON.stringify({ + [ID_DATA_STREAMS]: dataStreams + }), + function(data) { + // Hide the loading panel. + for (sensorID of sensorIDs) { + showSensorLoadingPopup(sensorID, false); + enableRefreshSensorButton(sensorID, true); + } + // Enable the refresh all sensors button. + if (!isSensorDialogShowing()) + enableRefreshAllSensorsButton(true); + // Process answer. + processDataStreamsAnswer(data); + } + ).fail(function(response) { + // Hide the loading panel. + for (sensorID of sensorIDs) { + showSensorLoadingPopup(sensorID, false); + enableRefreshSensorButton(sensorID, true); + } + // Enable the refresh all sensors button. + if (!isSensorDialogShowing()) + enableRefreshAllSensorsButton(true); + // Process error. + processAjaxErrorResponse(response); + }); +} + +// Shows or hides the loading panel of the given sensor. +function showSensorLoadingPopup(sensorID, show) { + var sensorElement = document.getElementById(sensorID); + if (sensorElement == null) + return; + var sensorLoadingElement = sensorElement.getElementsByClassName(CLASS_SENSOR_LOADING_PANEL)[0]; + if (sensorLoadingElement == null) + return; + if (show && sensorLoadingElement.classList.contains(CLASS_SENSOR_LOADING_HIDE)) + sensorLoadingElement.classList.remove(CLASS_SENSOR_LOADING_HIDE); + else if (!show && !sensorLoadingElement.classList.contains(CLASS_SENSOR_LOADING_HIDE)) + sensorLoadingElement.classList.add(CLASS_SENSOR_LOADING_HIDE); +} + +// Returns the list of streams of the given sensor. +function getSensorStreams(sensorID) { + if (sensorsList.get(sensorID) != null) + return sensorsList.get(sensorID)[ID_DATA_STREAMS]; + return null; +} + +// Enables or disables the refresh button of the given sensor. +function enableRefreshSensorButton(sensorID, enable) { + var sensorElement = document.getElementById(sensorID); + if (sensorElement == null) + return; + var sensorRefreshButtonElement = sensorElement.getElementsByClassName(CLASS_SENSOR_ID_REFRESH_BUTTON)[0]; + if (sensorRefreshButtonElement == null) + return; + sensorRefreshButtonElement.disabled = !enable; +} + +// Enables or disables the refresh all sensors button. +function enableRefreshAllSensorsButton(enable) { + document.getElementById(ID_REFRESH_BUTTON).disabled = !enable; +} + +// Processes the data streams answer. +function processDataStreamsAnswer(data) { + for (const [key, value] of Object.entries(data.data_streams)) + applyDataStream(key, value); +} + +// Applies the data stream to the corresponding HTML element. +function applyDataStream(streamID, value) { + // Sanity checks. + if (streamID == null || value == null) + return; + var sensorDataElement = document.getElementById(streamID); + if (sensorDataElement == null) + return; + if (sensorDataElement.innerHTML === value) + return; + // Apply value highlighting the HTML control. + sensorDataElement.innerHTML = value; + if (!sensorDataElement.classList.contains(CLASS_SENSOR_DATA_GREEN)) + sensorDataElement.classList.add(CLASS_SENSOR_DATA_GREEN); + setTimeout( function() { + if (sensorDataElement.classList.contains(CLASS_SENSOR_DATA_GREEN)) + sensorDataElement.classList.remove(CLASS_SENSOR_DATA_GREEN); + }, 500); +} + +// Subscribes to any datapoint change. +function subscribeDataPoints() { + // Sanity checks. + if (dataPointsSocket != null) + return; + var dataStreams = getAllDataStreams(); + if (dataStreams.length == 0) + return; + // Create the web socket. + var socketPrefix = window.location.protocol == "https:" ? "wss" : "ws"; + dataPointsSocket = new WebSocket(socketPrefix + "://" + window.location.host + "/ws/datapoints/"); + // Define the callback to be notified when data is received in the web socket. + dataPointsSocket.onmessage = function(e) { + // Initialize variables. + var event = JSON.parse(e.data); + // Check if the message contains an error. + if (event[ID_TYPE] != null && event[ID_TYPE] === ID_ERROR && event[ID_ERROR] != null && event[ID_ERROR]) { + toastr.error(event[ID_ERROR]); + return; + } + // Update the dataStream value. + applyDataStream(event[ID_STREAM], event[ID_VALUE]); + }; + // Send list of data streams to the web socket. + dataPointsSocket.onopen = function(e) { + dataPointsSocket.send(JSON.stringify(dataStreams)); + } +} + +// Closes the data points socket. +function unsubscribeDataPointsSocket() { + if (dataPointsSocket != null) { + dataPointsSocket.close(); + dataPointsSocket = null; + } +} + +// Returns a list with the data streams of all the sensors. +function getAllDataStreams() { + var dataStreams = [] + var sensorIDs = Array.from(sensorsList.keys()); + for (sensorID of sensorIDs) { + var sensorStreams = getSensorStreams(sensorID); + if (sensorStreams == null || sensorStreams.length == 0) + continue; + dataStreams.push.apply(dataStreams, sensorStreams); + } + return dataStreams; +} + +// Refreshes the DataPoints monitor. +function refreshDataPointsMonitor() { + // Close old datapoints monitor. + unsubscribeDataPointsSocket(); + // Add a new datapoints monitor. + subscribeDataPoints(); +} \ No newline at end of file diff --git a/sensor_lab/static_files/js/common.js b/sensor_lab/static_files/js/common.js new file mode 100644 index 0000000..486f71a --- /dev/null +++ b/sensor_lab/static_files/js/common.js @@ -0,0 +1,139 @@ +/* + * Copyright 2022, Digi International Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// Constants. +const CLASS_D_NONE = "d-none"; +const CLASS_ELEMENT_GRAYED = "element-grayed"; + +const ERROR_ABORTED = "Operation aborted"; +const ERROR_BAD_REQUEST = "Bad request"; +const ERROR_FORBIDDEN = "Could not perform the selected action. Make sure you have the correct access rights."; +const ERROR_URL_NOT_FOUND = "Requested URL not found"; +const ERROR_SERVER_ERROR = "Internal server error"; +const ERROR_TITLE = "Error"; +const ERROR_UNKNOWN_ERROR = "Unknown error. Make sure that server is running."; + +const ID_ERROR = "error"; +const ID_ERROR_GUIDE = "error_guide"; +const ID_ERROR_MESSAGE = "error_msg"; +const ID_ERROR_TITLE = "error_title"; +const ID_LOADING_POPUP = "loading_popup"; +const ID_LOADING_POPUP_MESSAGE = "loading_popup_message"; +const ID_LOADING_WRAPPER = "loading_wrapper"; + +const VALUE_ABORT = "abort"; + +String.prototype.format = function() { + var formatted = this; + for (var arg in arguments) + formatted = formatted.replaceAll("{" + arg + "}", arguments[arg]); + return formatted; +}; + +// Check if there is any error in the response. +function checkErrorResponse(response, showErrorDialog) { + if (response[ID_ERROR_MESSAGE] != null || response[ID_ERROR] != null) { + // Process error. + var errorTitle = ERROR_TITLE; + var errorMessage = getErrorFromResponse(response); + if (response[ID_ERROR_TITLE] != null) + errorTitle = response[ID_ERROR_TITLE]; + // Show toast error. + if (errorMessage != null && errorMessage != "") + toastr.error(errorMessage); + // Hide the loading panel. + showLoadingPopup(false); + // Show error dialog. + if (showErrorDialog) + showInfoPopup(true, errorTitle, errorMessage); + // There was an error, return true. + return true; + } + // No error found. + return false; +} + +// Returns the error message from the response. +function getErrorFromResponse(response) { + var error = ""; + if (response[ID_ERROR_MESSAGE] != null || response[ID_ERROR] != null) { + if (response[ID_ERROR_MESSAGE] != null) { + error = response[ID_ERROR_MESSAGE]; + if (response[ID_ERROR_GUIDE] != null) + error += response[ID_ERROR_GUIDE]; + } else + error = response[ID_ERROR]; + } + return error; +} + +// Shows/hides the loading popup panel. +function showLoadingPopup(visible, message=null) { + // Set loading message only if it is not null and the popup will be visible. + if (visible && message != null) + document.getElementById(ID_LOADING_POPUP_MESSAGE).innerHTML = message; + // Show/Hide the popup. + showPopup(ID_LOADING_WRAPPER, ID_LOADING_POPUP, visible); +} + +// Shows/hides a front popup over the given background element. +function showPopup(backElementID, frontElementID, visible) { + // Initialize variables. + var backElement = document.getElementById(backElementID); + var frontElement = document.getElementById(frontElementID); + // Sanity checks. + if (backElement == null || frontElement == null) + return; + // Show/Hide the popup. + if (visible) { + if (!backElement.classList.contains(CLASS_ELEMENT_GRAYED)) + backElement.classList.add(CLASS_ELEMENT_GRAYED); + if (frontElement.classList.contains(CLASS_D_NONE)) + frontElement.classList.remove(CLASS_D_NONE); + } else { + if (backElement.classList.contains(CLASS_ELEMENT_GRAYED)) + backElement.classList.remove(CLASS_ELEMENT_GRAYED); + if (!frontElement.classList.contains(CLASS_D_NONE)) + frontElement.classList.add(CLASS_D_NONE); + } +} + +// Processes the error response of the AJAX request. +function processAjaxErrorResponse(response) { + // Check common error codes. + if (response.status == 401) + redirectToLogin(); + var errorMessage = ""; + if (response.statusText == VALUE_ABORT) + errorMessage = ERROR_ABORTED; + else if (response.status == 400) { + errorMessage = response.responseJSON[ID_ERROR]; + // Show the error message (if any). + if (errorMessage == null) + errorMessage = ERROR_BAD_REQUEST; + } else if (response.status == 403) + errorMessage = ERROR_FORBIDDEN; + else if (response.status == 404) + errorMessage = ERROR_URL_NOT_FOUND; + else if (response.status == 500) + errorMessage = ERROR_SERVER_ERROR; + else + errorMessage = ERROR_UNKNOWN_ERROR; + // Show toast with the error message. + toastr.error(errorMessage); + // Return the error message. + return errorMessage; +} diff --git a/sensor_lab/static_files/js/jquery.fittext.js b/sensor_lab/static_files/js/jquery.fittext.js new file mode 100644 index 0000000..394cfc1 --- /dev/null +++ b/sensor_lab/static_files/js/jquery.fittext.js @@ -0,0 +1,43 @@ +/*global jQuery */ +/*! +* FitText.js 1.2 +* +* Copyright 2011, Dave Rupert http://daverupert.com +* Released under the WTFPL license +* http://sam.zoy.org/wtfpl/ +* +* Date: Thu May 05 14:23:00 2011 -0600 +*/ + +(function( $ ){ + + $.fn.fitText = function( kompressor, options ) { + + // Setup options + var compressor = kompressor || 1, + settings = $.extend({ + 'minFontSize' : Number.NEGATIVE_INFINITY, + 'maxFontSize' : Number.POSITIVE_INFINITY + }, options); + + return this.each(function(){ + + // Store the object + var $this = $(this); + + // Resizer() resizes items based on the object width divided by the compressor * 10 + var resizer = function () { + $this.css('font-size', Math.max(Math.min($this.width() / (compressor*10), parseFloat(settings.maxFontSize)), parseFloat(settings.minFontSize))); + }; + + // Call once to set. + resizer(); + + // Call on resize. Opera debounces their resize by default. + $(window).on('resize.fittext orientationchange.fittext', resizer); + + }); + + }; + +})( jQuery ); \ No newline at end of file diff --git a/sensor_lab/static_files/js/toastr.min.js b/sensor_lab/static_files/js/toastr.min.js new file mode 100644 index 0000000..b38c5d3 --- /dev/null +++ b/sensor_lab/static_files/js/toastr.min.js @@ -0,0 +1 @@ +!function(e){e(["jquery"],function(e){return function(){function t(e,t,n){return g({type:O.error,iconClass:m().iconClasses.error,message:e,optionsOverride:n,title:t})}function n(t,n){return t||(t=m()),v=e("#"+t.containerId),v.length?v:(n&&(v=d(t)),v)}function o(e,t,n){return g({type:O.info,iconClass:m().iconClasses.info,message:e,optionsOverride:n,title:t})}function s(e){C=e}function i(e,t,n){return g({type:O.success,iconClass:m().iconClasses.success,message:e,optionsOverride:n,title:t})}function a(e,t,n){return g({type:O.warning,iconClass:m().iconClasses.warning,message:e,optionsOverride:n,title:t})}function r(e,t){var o=m();v||n(o),u(e,o,t)||l(o)}function c(t){var o=m();return v||n(o),t&&0===e(":focus",t).length?void h(t):void(v.children().length&&v.remove())}function l(t){for(var n=v.children(),o=n.length-1;o>=0;o--)u(e(n[o]),t)}function u(t,n,o){var s=!(!o||!o.force)&&o.force;return!(!t||!s&&0!==e(":focus",t).length)&&(t[n.hideMethod]({duration:n.hideDuration,easing:n.hideEasing,complete:function(){h(t)}}),!0)}function d(t){return v=e("
").attr("id",t.containerId).addClass(t.positionClass),v.appendTo(e(t.target)),v}function p(){return{tapToDismiss:!0,toastClass:"toast",containerId:"toast-container",debug:!1,showMethod:"fadeIn",showDuration:300,showEasing:"swing",onShown:void 0,hideMethod:"fadeOut",hideDuration:1e3,hideEasing:"swing",onHidden:void 0,closeMethod:!1,closeDuration:!1,closeEasing:!1,closeOnHover:!0,extendedTimeOut:1e3,iconClasses:{error:"toast-error",info:"toast-info",success:"toast-success",warning:"toast-warning"},iconClass:"toast-info",positionClass:"toast-top-right",timeOut:5e3,titleClass:"toast-title",messageClass:"toast-message",escapeHtml:!1,target:"body",closeHtml:'',closeClass:"toast-close-button",newestOnTop:!0,preventDuplicates:!1,progressBar:!1,progressClass:"toast-progress",rtl:!1}}function f(e){C&&C(e)}function g(t){function o(e){return null==e&&(e=""),e.replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">")}function s(){c(),u(),d(),p(),g(),C(),l(),i()}function i(){var e="";switch(t.iconClass){case"toast-success":case"toast-info":e="polite";break;default:e="assertive"}I.attr("aria-live",e)}function a(){E.closeOnHover&&I.hover(H,D),!E.onclick&&E.tapToDismiss&&I.click(b),E.closeButton&&j&&j.click(function(e){e.stopPropagation?e.stopPropagation():void 0!==e.cancelBubble&&e.cancelBubble!==!0&&(e.cancelBubble=!0),E.onCloseClick&&E.onCloseClick(e),b(!0)}),E.onclick&&I.click(function(e){E.onclick(e),b()})}function r(){I.hide(),I[E.showMethod]({duration:E.showDuration,easing:E.showEasing,complete:E.onShown}),E.timeOut>0&&(k=setTimeout(b,E.timeOut),F.maxHideTime=parseFloat(E.timeOut),F.hideEta=(new Date).getTime()+F.maxHideTime,E.progressBar&&(F.intervalId=setInterval(x,10)))}function c(){t.iconClass&&I.addClass(E.toastClass).addClass(y)}function l(){E.newestOnTop?v.prepend(I):v.append(I)}function u(){if(t.title){var e=t.title;E.escapeHtml&&(e=o(t.title)),M.append(e).addClass(E.titleClass),I.append(M)}}function d(){if(t.message){var e=t.message;E.escapeHtml&&(e=o(t.message)),B.append(e).addClass(E.messageClass),I.append(B)}}function p(){E.closeButton&&(j.addClass(E.closeClass).attr("role","button"),I.prepend(j))}function g(){E.progressBar&&(q.addClass(E.progressClass),I.prepend(q))}function C(){E.rtl&&I.addClass("rtl")}function O(e,t){if(e.preventDuplicates){if(t.message===w)return!0;w=t.message}return!1}function b(t){var n=t&&E.closeMethod!==!1?E.closeMethod:E.hideMethod,o=t&&E.closeDuration!==!1?E.closeDuration:E.hideDuration,s=t&&E.closeEasing!==!1?E.closeEasing:E.hideEasing;if(!e(":focus",I).length||t)return clearTimeout(F.intervalId),I[n]({duration:o,easing:s,complete:function(){h(I),clearTimeout(k),E.onHidden&&"hidden"!==P.state&&E.onHidden(),P.state="hidden",P.endTime=new Date,f(P)}})}function D(){(E.timeOut>0||E.extendedTimeOut>0)&&(k=setTimeout(b,E.extendedTimeOut),F.maxHideTime=parseFloat(E.extendedTimeOut),F.hideEta=(new Date).getTime()+F.maxHideTime)}function H(){clearTimeout(k),F.hideEta=0,I.stop(!0,!0)[E.showMethod]({duration:E.showDuration,easing:E.showEasing})}function x(){var e=(F.hideEta-(new Date).getTime())/F.maxHideTime*100;q.width(e+"%")}var E=m(),y=t.iconClass||E.iconClass;if("undefined"!=typeof t.optionsOverride&&(E=e.extend(E,t.optionsOverride),y=t.optionsOverride.iconClass||y),!O(E,t)){T++,v=n(E,!0);var k=null,I=e("
"),M=e("
"),B=e("
"),q=e("
"),j=e(E.closeHtml),F={intervalId:null,hideEta:null,maxHideTime:null},P={toastId:T,state:"visible",startTime:new Date,options:E,map:t};return s(),r(),a(),f(P),E.debug&&console&&console.log(P),I}}function m(){return e.extend({},p(),b.options)}function h(e){v||(v=n()),e.is(":visible")||(e.remove(),e=null,0===v.children().length&&(v.remove(),w=void 0))}var v,C,w,T=0,O={error:"error",info:"info",success:"success",warning:"warning"},b={clear:r,remove:c,error:t,getContainer:n,info:o,options:{},subscribe:s,success:i,version:"2.1.4",warning:a};return b}()})}("function"==typeof define&&define.amd?define:function(e,t){"undefined"!=typeof module&&module.exports?module.exports=t(require("jquery")):window.toastr=t(window.jQuery)}); \ No newline at end of file