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 %}
+
+{% 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 = """
+
+"""
+
+
+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
+
+
+
+
+
+
Loading devices...
+
+{% 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}" +
+ "
" +
+ "
" +
+ " " +
+ "
Refreshing data...
" +
+ "
" +
+ "
";
+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