├── .gitignore ├── Blynk ├── Blynk_dashboard.png ├── README.md └── requirements.txt ├── IBM ├── IBM_dashboard.png ├── README.md └── requirements.txt ├── KAA ├── Kaa_dashboard.png ├── README.md └── requirements.txt ├── MicroPython ├── MicroPython_firmware │ └── esp32-20210623-v1.16.bin ├── README.md ├── requirements.txt ├── scripts │ ├── cloud_credentials.py │ ├── common │ │ ├── cloud_providers.py │ │ ├── common_variables.py │ │ └── utilities.py │ ├── communication │ │ └── provision_client.py │ ├── generate_terraform.py │ ├── pyboard.py │ ├── register_device.py │ ├── requirements.txt │ ├── upload_all.py │ ├── upload_micropython.py │ └── upload_scripts.py ├── src │ ├── boot.py │ ├── cloud │ │ ├── AWS_cloud.py │ │ ├── Blynk_cloud.py │ │ ├── IBM_cloud.py │ │ ├── KAA_cloud.py │ │ ├── ThingsBoard_cloud.py │ │ └── cloud_interface.py │ ├── common │ │ ├── __init__.py │ │ ├── config.py │ │ └── utils.py │ ├── communication │ │ └── wirerless_connection_controller.py │ ├── controller │ │ ├── main_controller.py │ │ ├── main_controller_event.py │ │ └── main_controller_state.py │ ├── data_acquisition │ │ └── data_acquisitor.py │ ├── data_upload │ │ └── mqtt_communicator.py │ ├── lib │ │ ├── BlynkLib.py │ │ ├── bme280.py │ │ ├── logging.py │ │ ├── picoweb │ │ │ ├── __init__.py │ │ │ └── utils.py │ │ ├── pkg_resources.py │ │ ├── uasyncio │ │ │ ├── __init__.py │ │ │ └── core.py │ │ └── ulogging.py │ ├── main.py │ ├── peripherals │ │ └── sensor.py │ ├── utime.py │ └── web_server │ │ ├── web_app.py │ │ └── web_pages │ │ ├── add_icon.png │ │ ├── delete_icon.png │ │ ├── favicon-32x32-1.png │ │ ├── form.js │ │ ├── index.html │ │ ├── logo_wizzdev.png │ │ ├── logo_wizzdev.svg │ │ ├── server_info.html │ │ ├── setup.html │ │ └── style.css └── ulib_mocks │ ├── dht.py │ ├── esp.py │ ├── esp32.py │ ├── machine.py │ ├── network.py │ ├── ntptime.py │ ├── ujson.py │ ├── umqtt │ └── simple.py │ ├── uos.py │ ├── ure.py │ └── utime.py ├── README.md ├── ThingsBoard ├── README.md ├── ThingsBoard_dashboard.png └── requirements.txt ├── db_access ├── .gitignore ├── README.md ├── __init__.py ├── common │ ├── __init__.py │ ├── config.py │ ├── errors.py │ └── util.py ├── example_1.py ├── example_2.py ├── model │ ├── __init__.py │ ├── base_model.py │ ├── device_model.py │ ├── measurement_model.py │ └── user_model.py ├── requirements.txt ├── service │ ├── __init__.py │ ├── base_service.py │ ├── device_service.py │ └── measurement_service.py └── view │ ├── __init__.py │ ├── base_view.py │ ├── device_view.py │ └── measurement_view.py ├── lamba_health_check ├── __init__.py ├── checks.py ├── main.py └── requirements.txt ├── lambda_collect_measurements ├── __init__.py ├── main.py └── requirements.txt ├── requirements.txt ├── terraform ├── README.md ├── environments │ ├── devel.tfvars │ └── production.tfvars ├── main.tf ├── modules │ ├── iot_core │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── policies.tf │ │ └── vars.tf │ ├── lambdas │ │ ├── collect_measurements.tf │ │ ├── health_check.tf │ │ ├── locals.tf │ │ ├── policies.tf │ │ └── vars.tf │ ├── rest_api │ │ ├── locals.tf │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── policies.tf │ │ └── vars.tf │ └── web_visualization │ │ ├── certificate.tf │ │ ├── cloudfront.tf │ │ ├── outputs.tf │ │ ├── s3_bucket.tf │ │ └── vars.tf ├── scripts │ ├── build_all.py │ ├── build_frontend.py │ ├── build_lambda.py │ ├── requirements.txt │ └── upload_frontend.py ├── sentry.tf └── vars.tf ├── utils └── connect_as_thing_with_iot_core.py └── web_server ├── README.md ├── client ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── babel.config.js ├── package-lock.json ├── package.json ├── src │ ├── App.vue │ ├── common │ │ ├── api.helper.js │ │ ├── colors.helper.js │ │ ├── line.chart.js │ │ └── utils.js │ ├── components │ │ ├── devices.table.vue │ │ ├── measurement_types.charts.vue │ │ ├── measurements.chart.vue │ │ └── measurements.table.vue │ ├── config.js │ ├── i18n.js │ ├── main.js │ ├── router.js │ ├── store │ │ ├── actions.js │ │ ├── index.js │ │ ├── modules │ │ │ ├── device.group.js │ │ │ ├── device.js │ │ │ ├── device.shadow.js │ │ │ ├── device.type.js │ │ │ ├── measurement.js │ │ │ └── measurement.type.js │ │ └── mutations.js │ └── views │ │ ├── dashboard.view.vue │ │ ├── device.configuration.view.vue │ │ ├── device.measurements.view.vue │ │ ├── device.view.vue │ │ └── devices.table.view.vue └── static │ └── material_icons.css ├── images ├── logo_wizzdev.png └── logo_wizzdev.svg ├── misc └── fill_with_dummy_data.py └── server ├── app.py ├── client ├── .keep └── favicon.png ├── config.py ├── core ├── certificates_helper.py ├── request_arguments_parser.py ├── response_factory.py ├── serializer.py ├── things_helper.py ├── user_service.py └── utils.py ├── requirements.txt ├── serializers ├── device_serializer.py ├── measurement_serializer.py └── user_serializer.py ├── views ├── __init__.py ├── auth_view.py ├── device_group_view_set.py ├── device_type_view_set.py ├── device_view_set.py ├── measurement_type_view_set.py └── measurement_view_set.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # terraform temps 2 | terraform/.tmp/** 3 | terraform/.terraform/* 4 | terraform/.terraform.lock.hcl 5 | 6 | # terraform bin 7 | terraform/terraform 8 | terraform/terraform.exe 9 | 10 | #JSON cloud configuration file 11 | MicroPython/src/*.json 12 | 13 | # Python cache files 14 | *.pyc 15 | venv/ 16 | .idea/ 17 | *.vscode/ 18 | .env 19 | 20 | # Certs and creds 21 | *credentials.txt 22 | ca.pem 23 | 24 | # Misc files 25 | *.info 26 | 27 | #Flash setup 28 | sdkconfig 29 | 30 | 31 | # Hash of last commit 32 | commit_hash.txt 33 | 34 | 35 | #*moc* 36 | *.backup* 37 | *LOCAL* 38 | !locals.tf 39 | -------------------------------------------------------------------------------- /Blynk/Blynk_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/Blynk/Blynk_dashboard.png -------------------------------------------------------------------------------- /Blynk/README.md: -------------------------------------------------------------------------------- 1 | # Set up account and device on Blynk cloud platform 2 | The instruction walks you through the process of creating your own IoT device in just a few steps. 3 | 4 | An example web dashboard that might be created can look like this: 5 |  6 | 7 | ## What is Blynk 8 | [Blynk](https://blynk.io/) is a cloud service provider allowing for building and managing connected hardware: device provisioning, sensor data visualization, remote control with mobile and web applications, over-the-air firmware updates and much more. 9 | 10 | ## Installation 11 | 12 | ### Install requirements 13 | 14 | After you've created virtual environment, your current directory should be "iot-starter" 15 | 16 | ``` 17 | pip install -r Blynk/requirements.txt 18 | ``` 19 | 20 | ## Set up Blynk account 21 | 22 | ### 1. Create account 23 | 24 | First, you need to create an account on Blynk cloud website: [blynk.cloud](https://blynk.cloud/dashboard/register). Follow standard registration procedure providing your email and then activating the account. 25 | 26 | ### 2. Creating template and your device 27 | 28 | After you first log in into your new account on Blynk,Console you'll be probably prompted with a quickstart device. Please close the window as we're going to do it manually. 29 | 30 | #### **Template**: 31 | 32 | Template will be used for creating your new IoT-Starter device. 33 | 34 | 1. On the left side pane search for **"Templates"** option. 35 | 2. Click on **"+ New Template"** with the name and description of your choice. Make sure to use hardware **"ESP32"** and connection type **"WiFi"**. After creating the template you can modify it further adding an image, metadata etc. 36 | 3. Now you need to create two **"Datastreams"** that are responsible for accepting telemetry data (temperature and humidity) from our device. To do that go to: 37 | **"Datastreams" --> "+ New Datastream" --> "Virtual Pin"** 38 | Please make two virtual pin datastreams for temperature and humidity. **Please note** which pin is responsible for each telemetry type (only number matters, so if you're assigning **V0** to temperature, the pin responsible for the temperature telemetry is no. **0**). Make sure that the **"Data Type"** is set to **"Double"**. You can also add **"Units"** to the pins (Celsius for the temperature and percentage for the humidity) - it is optional though. Please also adjust the **min** and **max** value for each pin (0 - 100 for the humidity and for example 10 - 40 for the temperature) as well as check the **"Thousands seperator"** option. 39 | 4. After creating both virtual pins you can set up a web dashboard after clicking **"Web dashboard"** option. (**"Mobile dashboard"** can be only created in **"Blynk IoT"** mobile app.) 40 | 5. Save the changes made to the template. 41 | 42 | #### **Device**: 43 | 44 | Device will be created from already created template, so all you need to do is: 45 | 46 | 1. Click on the left side pane **"Search"** --> **"+ New Device"** --> **"From template"** 47 | 2. Select your template and give some meaningful name to your device. 48 | 3. Click on the created device and go to **"Device info"** and on the right side look for **"BLYNK_AUTH_TOKEN"**. It should look like this: 49 | ``` 50 | #define BLYNK_AUTH_TOKEN ""; 51 | ``` 52 | Copy this long sequence as you will need it in a moment. 53 | 54 | After all steps above you should have saved three things: 55 | - Virtual pin for temperature 56 | - Virtual pin for humidity 57 | - Device auth token 58 | -------------------------------------------------------------------------------- /Blynk/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./../MicroPython/requirements.txt -------------------------------------------------------------------------------- /IBM/IBM_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/IBM/IBM_dashboard.png -------------------------------------------------------------------------------- /IBM/README.md: -------------------------------------------------------------------------------- 1 | # Set up account and device on IBM Watson IoT platform 2 | The instruction walks you through the process of creating your own IoT device in just a few steps. 3 | 4 | An example dashboard that might be created can look like this: 5 |  6 | 7 | ## What is IBM Watson IoT Platform 8 | [IBM Watson IoT Platform](https://internetofthings.ibmcloud.com/) is a clean and simple UI where you can simply and easily add and manage your devices, control access to your IoT service, and monitor your usage. It's free of charge with a limit of up to 500 devices and 200MB of data transfer. 9 | 10 | ## Installation 11 | 12 | ### Install requirements 13 | 14 | After you've created virtual environment, your current directory should be "iot-starter" 15 | 16 | ``` 17 | pip install -r IBM/requirements.txt 18 | ``` 19 | 20 | ## Set up IBM Watson account 21 | 22 | If you are stuck or something will be unclear in next few steps, we recommend looking at official IBM documentation about Getting Started at this [link](https://cloud.ibm.com/docs/IoT/devices/mqtt.html#). 23 | 24 | ### 1. Create account 25 | 26 | First, you need to create an account on IBM Watson IoT Platform website: [ibm.com](https://internetofthings.ibmcloud.com/). 27 | 28 | 29 | ### 2. Create your first application and device endpoint 30 | 31 | Create an IoT Platform Service Lite instance directly from the [Platform Service Page in the IBM Cloud Service Catalog](https://cloud.ibm.com/catalog/services/internet-of-things-platform). 32 | On the service page, click Launch to start administering your IBM Watson IoT Platform organization. 33 | The IBM Watson IoT Platform web console opens in a new browser tab at the following URL: 34 | https://.internetofthings.ibmcloud.com/dashboard/#/overview Where org_id is the ID of your IBM Watson IoT Platform organization. 35 | 36 | ### 3. Create a device 37 | 38 | 1. In your IBM Watson IoT Platform dashboard, hover over the left side panel, and choose "Devices". 39 | 2. Click "Add Device" in the top right corner. 40 | 3. Create a device type. The device type name can be up to 36 characters long and contain only: Alpha-numeric characters, Hyphens(-), Underscores(_) and Periods(.). For example (IoTStarter) 41 | 4. Enter a device ID and click "Next". 42 | 5. Optional: Enter device type attributes and metadata and click "Next". (You can add and edit it later) 43 | 6. Enter an Authentication Token or click "Next" to auto-generate a Token. 44 | 7. Verify your information about the device and select "Finish" to add the device 45 | 8. IMPORTANT: Save your device credentials, as you will need them to connect a device. The Authentication token is non-recoverable. 46 | 47 | After all steps above, you should have five things: 48 | - Organization ID 49 | - Device Type 50 | - Device ID 51 | - Authentication Method: "use-token-auth" by default 52 | - Authentication Token 53 | 54 | -------------------------------------------------------------------------------- /IBM/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./../MicroPython/requirements.txt -------------------------------------------------------------------------------- /KAA/Kaa_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/KAA/Kaa_dashboard.png -------------------------------------------------------------------------------- /KAA/README.md: -------------------------------------------------------------------------------- 1 | # Set up account and device on KaaIoT cloud platform 2 | The instruction walks you through the process of creating your own IoT device in just a few steps. 3 | 4 | An example dashboard that might be created can look like this: 5 |  6 | 7 | ## What is KaaIoT 8 | [KaaIoT](https://www.kaaiot.com/) is a cloud service provider allowing for device management, data collection and visualization. You can create a powerful dashboard in a matter of seconds. It's free of charge with a limit of up to 5 devices. 9 | 10 | ## Installation 11 | 12 | ### Install requirements 13 | 14 | After you've created virtual environment, your current directory should be "iot-starter" 15 | 16 | ``` 17 | pip install -r KAA/requirements.txt 18 | ``` 19 | 20 | ## Set up KaaIoT account 21 | 22 | If you are stuck or something will be unclear in next few steps, we recommend looking at official Kaa documentation about connecting your first device at this [link](https://docs.kaaiot.io/KAA/docs/v1.3.0/Tutorials/getting-started/connecting-your-first-device/). 23 | 24 | ### 1. Create account 25 | 26 | First, you need to create an account on KaaIoT website: [kaaiot.com](https://www.kaaiot.com/). 27 | Click at the "Use Kaa for free" button at top right corner and follow standard procedure of registration. 28 | 29 | ### 2. Create your first application and device endpoint 30 | 31 | After you log in into your new account (as a root user!), familiarize yourself with the UI and the options at the left side pane. 32 | 33 | #### **Application version and autoextract:** 34 | 35 | 1. Hover over the "Device management" option (first one) and select "Applications". In the right top corner click "Add application" and enter the name of your application and (optional) description. 36 | 2. Click on the created application to expand it. On the right side look for "epts" and click on it. There you should see an option **"Autoextract"**. Make sure that the checkbox is checked. 37 | 3. Go back to the application menu and expand the created application again. On the left side of the expanded window you will see “versions”. Click on the plus sign next to add a new version. You should see the "Name" field with a long sequence of random characters (like this one: c2t3ac6gul4q7qik0ol0-). There you should specify a version of your application. Let's go with something simple, add "v1" at the input field. **It is important for you to write this application version down as we will need it later - in this example it looks like: "c2t3ac6gul4q7qik0ol0-v1"**. You can add some optional display name and description for your convenience. After it is created, repeat step 2. but for the created version. 38 | 39 | #### **Device endpoint:** 40 | 41 | 1. Hover over the "Device management" option (first one) and select "Devices". In the right top corner select the created application and then "Add device". Make sure you select a good application version and then supply an endpoint token. **IMPORTANT: Make sure to save this endpoint token as you cannot retrieve it later if you forget it.** After that, expand the "Additional metadata" field and click on "Add property". In the "Key" field supply "Label" and in the "Value" some unique identifier for this device (for example IoT-Starter). Click create, save endpoint token. 42 | 43 | 2. Lastly, you need to create authentication credentials. For that go to "Device management" and click on "Credentials". On the right side look for a button with "Add basic credentials". On the left side of this button there are three smaller (symbol) buttons. Turn on the first two (plain MQTT and MQTT/TLS). After that all you need to do is create basic credentials, with username and password of your choice. **Remember to save both username and password as you will need it later.** Be careful as username is appended with your tenant id so it will look like: "username@b8e42266-8900-48d1-b99c-165f52j1d4f2"! (Password is not recoverable so make sure you save it for sure!) 44 | 45 | 46 | After all steps above you should have saved four things: 47 | - application version 48 | - device endpoint token 49 | - username 50 | - password 51 | -------------------------------------------------------------------------------- /KAA/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./../MicroPython/requirements.txt -------------------------------------------------------------------------------- /MicroPython/MicroPython_firmware/esp32-20210623-v1.16.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/MicroPython_firmware/esp32-20210623-v1.16.bin -------------------------------------------------------------------------------- /MicroPython/requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | paho-mqtt==1.5.1 3 | esptool==2.8 4 | gitpython==3.1.17 5 | adafruit-ampy==1.0.5 6 | docopt>=0.6.2,<0.7 7 | pyserial==3.5 8 | cryptography==3.4.8 9 | click==8.0.1 10 | future==0.18.2 11 | pyelftools==0.27 12 | setuptools==47.1.0 -------------------------------------------------------------------------------- /MicroPython/scripts/common/cloud_providers.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | 4 | class Providers: 5 | AWS = "AWS" 6 | KAA = "KAA" 7 | THINGSBOARD = "THINGSBOARD" 8 | BLYNK = "BLYNK" 9 | IBM = "IBM" 10 | 11 | @classmethod 12 | def print_providers(cls) -> str: 13 | return "{}, {}, {}, {}, {}".format( 14 | cls.AWS, cls.KAA, cls.THINGSBOARD, cls.BLYNK, cls.IBM) 15 | 16 | @classmethod 17 | def get_providers(cls) -> Tuple[str, ...]: 18 | return cls.AWS, cls.KAA, cls.THINGSBOARD, cls.BLYNK, cls.IBM 19 | -------------------------------------------------------------------------------- /MicroPython/scripts/common/common_variables.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | CURRENT_WORKING_DIR = os.path.abspath(os.getcwd()) 5 | CLOUD_CONFIG_NAME = "{}_config.json" 6 | CLOUD_CONFIG_DIR = os.path.join(CURRENT_WORKING_DIR, 'src') 7 | CLOUD_CONFIG_PATH = os.path.join(CLOUD_CONFIG_DIR, CLOUD_CONFIG_NAME) 8 | CONFIG_OUTPUT_FILE_NAME = 'config.json' 9 | CONFIG_OUTPUT_FILE_DIR = os.path.join(CURRENT_WORKING_DIR, 'src') 10 | CONFIG_OUTPUT_FILE_PATH = os.path.join(CONFIG_OUTPUT_FILE_DIR, CONFIG_OUTPUT_FILE_NAME) -------------------------------------------------------------------------------- /MicroPython/scripts/common/utilities.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def file_exists(path: str) -> bool: 5 | if Path(path).is_file(): 6 | return True 7 | else: 8 | return False 9 | 10 | 11 | def remove_file(path: str, suppress=False) -> None: 12 | if file_exists(path): 13 | Path(path).unlink() 14 | else: 15 | if not suppress: 16 | print("File under path: {} does not exist!".format(path)) 17 | -------------------------------------------------------------------------------- /MicroPython/scripts/communication/provision_client.py: -------------------------------------------------------------------------------- 1 | from json import dumps, loads 2 | 3 | from common.utilities import file_exists, remove_file 4 | from paho.mqtt.client import Client 5 | 6 | RESULT_CODES = { 7 | 1: "incorrect protocol version", 8 | 2: "invalid client identifier", 9 | 3: "server unavailable", 10 | 4: "bad username or password", 11 | 5: "not authorised", 12 | } 13 | 14 | CREDENTIALS_PATH = 'src/credentials.txt' 15 | 16 | 17 | class ProvisionClient(Client): 18 | PROVISION_REQUEST_TOPIC = "/provision/request" 19 | PROVISION_RESPONSE_TOPIC = "/provision/response" 20 | 21 | def __init__(self, host, port, provision_request): 22 | super().__init__() 23 | self._host = host 24 | self._port = port 25 | self._username = "provision" 26 | self.on_connect = self.__on_connect 27 | self.on_message = self.__on_message 28 | self.__provision_request = provision_request 29 | 30 | def __on_connect(self, client, userdata, flags, rc): # Callback for connect 31 | if rc == 0: 32 | print("Provisioning device to ThingsBoard") 33 | client.subscribe(self.PROVISION_RESPONSE_TOPIC) 34 | provision_request = dumps(self.__provision_request) 35 | client.publish(self.PROVISION_REQUEST_TOPIC, provision_request) 36 | else: 37 | print("Cannot connect to ThingsBoard!, result: {}".format( 38 | RESULT_CODES[rc])) 39 | 40 | def __on_message(self, client, userdata, msg): 41 | decoded_payload = msg.payload.decode("UTF-8") 42 | decoded_message = loads(decoded_payload) 43 | provision_device_status = decoded_message.get("status") 44 | if provision_device_status == "SUCCESS": 45 | self.__save_credentials(decoded_message["credentialsValue"]) 46 | print("Provisioning successful! New device registered.") 47 | else: 48 | print("Provisioning was unsuccessful with status {} and message: {}".format( 49 | provision_device_status, decoded_message["errorMsg"])) 50 | # Needed as it can cause errors while checking if device was created 51 | remove_file(CREDENTIALS_PATH, suppress=True) 52 | 53 | self.disconnect() 54 | 55 | def provision(self): 56 | self.__clean_credentials() 57 | self.connect(self._host, self._port, 60) 58 | self.loop_forever() 59 | 60 | def get_new_client(self): 61 | client_credentials = self.get_credentials() 62 | if client_credentials is None: 63 | client_credentials = {} 64 | else: 65 | client_credentials = loads(client_credentials) 66 | 67 | new_client = None 68 | if client_credentials: 69 | new_client = Client(client_id=client_credentials["clientId"]) 70 | new_client.username_pw_set( 71 | client_credentials["userName"], client_credentials["password"]) 72 | else: 73 | print("Cannot read credentials from file!") 74 | return new_client 75 | 76 | @staticmethod 77 | def get_credentials(): 78 | new_credentials = None 79 | if file_exists(CREDENTIALS_PATH): 80 | with open(CREDENTIALS_PATH, "r") as credentials_file: 81 | new_credentials = credentials_file.read() 82 | return new_credentials 83 | 84 | @staticmethod 85 | def __save_credentials(credentials): 86 | with open(CREDENTIALS_PATH, "w") as credentials_file: 87 | credentials_file.write(dumps(credentials)) 88 | 89 | @staticmethod 90 | def __clean_credentials(): 91 | if file_exists(CREDENTIALS_PATH): 92 | open(CREDENTIALS_PATH, "w").close() 93 | -------------------------------------------------------------------------------- /MicroPython/scripts/generate_terraform.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import json 4 | import subprocess 5 | import shutil 6 | import sys 7 | 8 | TERRAFORM_DIR = "../terraform" 9 | ROOT_DIR = os.path.abspath(os.path.join( 10 | os.path.dirname(os.path.abspath(__file__)), '..')) 11 | TERRAFORM_BINARY_NAME = "terraform" 12 | TERRAFORM_LOCAL_EXE = "./terraform" 13 | 14 | 15 | def save_terraform_output_as_file(_output_file_name: str): 16 | # create paths to terraform directory 17 | terraform_full_path = os.path.join(ROOT_DIR, TERRAFORM_DIR) 18 | 19 | # change current working directory 20 | # it is necessary because terraform is initialized in cwd 21 | old_cwd = os.getcwd() 22 | os.chdir(terraform_full_path) 23 | 24 | # get terraform output 25 | tf_output_str = create_terraform_output() 26 | 27 | # come back to old working directory 28 | os.chdir(old_cwd) 29 | 30 | # save output to file 31 | print("Saving terraform output to file...") 32 | with open(_output_file_name, 'w') as outfile: 33 | outfile.write(tf_output_str) 34 | 35 | 36 | def _check_terraform(): 37 | """Checks if terraform binary exists in the system""" 38 | 39 | terraform_path = shutil.which(TERRAFORM_BINARY_NAME) 40 | if terraform_path is not None: 41 | # Terraform installed on the system 42 | return TERRAFORM_BINARY_NAME 43 | elif pathlib.Path(TERRAFORM_LOCAL_EXE).exists(): 44 | # Terraform binary exists inside terraform dir 45 | return TERRAFORM_LOCAL_EXE 46 | else: 47 | print("No terraform binary file found! Aborting.") 48 | sys.exit(1) 49 | 50 | 51 | def create_terraform_output() -> str: 52 | terraform = _check_terraform() 53 | 54 | subprocess.run([terraform, "init"]) 55 | subprocess.run([terraform, "workspace", "select", "production"]) 56 | proc = subprocess.Popen( 57 | [terraform, 'output', '-json'], stdout=subprocess.PIPE) 58 | output = proc.stdout.read() 59 | return get_string_from_byte(output) 60 | 61 | 62 | def get_string_from_byte(_output: bytes) -> str: 63 | output_json = _output.decode('utf-8') 64 | output_dict = json.loads(output_json) 65 | output_str = json.dumps(output_dict, indent=4, sort_keys=True) 66 | return output_str 67 | -------------------------------------------------------------------------------- /MicroPython/scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | esptool==2.8 3 | gitpython 4 | -------------------------------------------------------------------------------- /MicroPython/scripts/upload_all.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import time 5 | 6 | from cloud_credentials import set_credentials 7 | from common.cloud_providers import Providers 8 | from generate_terraform import save_terraform_output_as_file 9 | from upload_micropython import erase_chip, flash_micropython 10 | from upload_scripts import flash_scripts 11 | from common.common_variables import CLOUD_CONFIG_PATH, CONFIG_OUTPUT_FILE_NAME, CONFIG_OUTPUT_FILE_PATH 12 | 13 | 14 | def parse_arguments(): 15 | parser = argparse.ArgumentParser() 16 | parser.add_argument('-p', '--port', metavar='PORT', type=str, required=True, 17 | help="Com port of the device") 18 | parser.add_argument('-c', '--cloud', metavar='CLOUD', type=str, required=True, 19 | help="Cloud provider for IoT Starter: {}".format( 20 | Providers.print_providers())) 21 | parser.add_argument('-s', '--sensor', metavar='SENSOR', type=str, required=False, 22 | help="Sensor type in use (defaults to DHT22)") 23 | 24 | args = vars(parser.parse_args()) 25 | return args 26 | 27 | 28 | def save_additional_arguments(cloud_provider, sensor_type): 29 | """ 30 | Save additional arguments (cloud provider and sensor in use) 31 | This script cannot access config file so it needs to create config file in advance 32 | """ 33 | if sensor_type == None: 34 | sensor_type = "DHT22" 35 | 36 | cfg = {'cloud_provider': cloud_provider, 'sensor_type': sensor_type} 37 | with open(CONFIG_OUTPUT_FILE_PATH, 'w') as outfile: 38 | json.dump(cfg, outfile) 39 | 40 | 41 | if __name__ == '__main__': 42 | args = parse_arguments() 43 | 44 | cloud_config_file_path = CLOUD_CONFIG_PATH.format(args['cloud'].lower()) 45 | if args['cloud'] == Providers.AWS: 46 | if not os.path.isfile(cloud_config_file_path): 47 | print("Generating terraform output..") 48 | save_terraform_output_as_file(cloud_config_file_path) 49 | elif args['cloud'] in (Providers.KAA, Providers.THINGSBOARD, Providers.BLYNK, Providers.IBM): 50 | set_credentials(args['cloud']) 51 | else: 52 | raise Exception("Wrong cloud provider! Only: {} are valid".format( 53 | Providers.print_providers())) 54 | 55 | save_additional_arguments(args['cloud'], args['sensor']) 56 | erase_chip(args['port']) 57 | flash_micropython(args['port']) 58 | time.sleep(4) 59 | flash_scripts(args['port'], cloud_config_file_path, args['cloud'], CONFIG_OUTPUT_FILE_NAME) 60 | -------------------------------------------------------------------------------- /MicroPython/scripts/upload_micropython.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | import esptool 5 | 6 | # esptool.py --chip esp32 --port "$PORT" erase_flash 7 | 8 | MICROPYTHON_BIN_FILE_NAME = "esp32-20210623-v1.16.bin" 9 | MICROPYTHON_BIN_FILE_DIR = "MicroPython_firmware/" 10 | ROOT_DIR = os.path.abspath(os.path.join( 11 | os.path.dirname(os.path.abspath(__file__)), '..')) 12 | 13 | 14 | # read -n 1 -s -r -p "Reset ESP32 into bootloader mode - Hold BOOT button and click EN button. Release BOOT. Then press any key to continue" 15 | def erase_chip_advanced(port): 16 | print('Erasing Chip...') 17 | esp = esptool.ESPLoader.detect_chip(port=port) 18 | print(f'Detected chip: {esp.get_chip_description()}') 19 | 20 | print("Features: %s" % ", ".join(esp.get_chip_features())) 21 | print("Crystal is %dMHz" % esp.get_crystal_freq()) 22 | mac = esp.read_mac() 23 | print(f"MAC: {':'.join(map(lambda x: '%02x' % x, mac))}") 24 | 25 | esp.run_stub() 26 | esptool.erase_flash(esp, None) 27 | 28 | print('Hard resetting via RTS pin...') 29 | esp.hard_reset() 30 | 31 | 32 | def erase_chip(port): 33 | command = ["--chip", "esp32", "--port", port, "erase_flash"] 34 | esptool.main(command) 35 | 36 | 37 | def flash_micropython(port): 38 | micropython_bin_file_path = os.path.join( 39 | ROOT_DIR, MICROPYTHON_BIN_FILE_DIR, MICROPYTHON_BIN_FILE_NAME) 40 | 41 | command = ["--chip", "esp32", "--port", port, 42 | "--baud", "230400", "--after", "hard_reset", 43 | "write_flash", "-z", "0x1000", micropython_bin_file_path] 44 | esptool.main(command) 45 | 46 | 47 | def parse_arguments(): 48 | parser = argparse.ArgumentParser() 49 | parser.add_argument('-p', '--port', metavar='PORT', type=str, required=True, 50 | help="Com port of the device") 51 | args = vars(parser.parse_args()) 52 | return args 53 | 54 | 55 | if __name__ == '__main__': 56 | args = parse_arguments() 57 | 58 | erase_chip(args['port']) 59 | flash_micropython(args['port']) 60 | -------------------------------------------------------------------------------- /MicroPython/src/boot.py: -------------------------------------------------------------------------------- 1 | from uos import statvfs 2 | from gc import collect, enable, mem_alloc, mem_free, threshold 3 | from logging import DEBUG, basicConfig, debug, getLogger 4 | from common import utils 5 | 6 | # Logger config 7 | basicConfig(level=DEBUG) 8 | main_logger = getLogger(None) 9 | main_logger.setLevel(DEBUG) 10 | 11 | # Info about start 12 | debug("=== BOOT START ===") 13 | utils.print_reset_wake_state() 14 | 15 | # Garbage Collector 16 | enable() 17 | threshold(mem_free() // 4 + mem_alloc()) 18 | 19 | # Diagnostic info - not needed in production 20 | collect() 21 | debug("Free memory: " + str(mem_free())) 22 | fs_stat = statvfs('//') 23 | debug("Free flash: {} MB".format((fs_stat[0]*fs_stat[3])/1048576)) 24 | 25 | del fs_stat 26 | -------------------------------------------------------------------------------- /MicroPython/src/cloud/IBM_cloud.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import machine 3 | import ujson 4 | 5 | from common import config, utils 6 | from communication import wirerless_connection_controller 7 | from controller.main_controller_event import MainControllerEventType 8 | from cloud.cloud_interface import CloudProvider 9 | 10 | 11 | class IBMCloud(CloudProvider): 12 | def configure_data(self) -> None: 13 | """ 14 | Setup data from ibm_config file and save it to general config file 15 | :return: None 16 | """ 17 | logging.debug("IBM_cloud/configure_data()") 18 | ibm_configuration = self.load_ibm_config_from_file() 19 | 20 | config.cfg.ibm_user = ibm_configuration.get( 21 | "ibm_user", config.DEFAULT_IBM_USER) 22 | 23 | config.cfg.ibm_password = ibm_configuration.get( 24 | "ibm_password", config.DEFAULT_IBM_PASSWORD) 25 | config.cfg.ibm_device_id = ibm_configuration.get( 26 | "ibm_device_id", config.DEFAULT_IBM_DEVICE_ID) 27 | config.cfg.ibm_organization_id = ibm_configuration.get( 28 | "ibm_organization_id", config.DEFAULT_IBM_ORGANIZATION_ID) 29 | config.cfg.ibm_event_id = ibm_configuration.get( 30 | "ibm_event_id", config.DEFAULT_IBM_EVENT_ID) 31 | config.cfg.ibm_device_type = ibm_configuration.get( 32 | "ibm_device_type", config.DEFAULT_IBM_DEVICE_TYPE) 33 | 34 | config.cfg.ibm_client_id = 'd:{}:{}:{}'.format( 35 | config.cfg.ibm_organization_id, config.cfg.ibm_device_type, config.cfg.ibm_device_id) 36 | config.cfg.ibm_topic = 'iot-2/evt/{}/fmt/json'.format( 37 | config.cfg.ibm_event_id) 38 | config.cfg.ibm_host = '{}.messaging.internetofthings.ibmcloud.com'.format( 39 | config.cfg.ibm_organization_id) 40 | 41 | config.cfg.save() 42 | 43 | def load_ibm_config_from_file(self) -> dict: 44 | """ 45 | Load configuration of IBM from file. 46 | :return: Configuration in dict. 47 | """ 48 | if utils.check_if_file_exists(config.IBM_CONFIG_PATH) == 0: 49 | raise Exception("Create ibm_config.json file first!") 50 | 51 | with open(config.IBM_CONFIG_PATH, "r", encoding="utf8") as infile: 52 | config_dict = ujson.load(infile) 53 | 54 | return config_dict 55 | 56 | def _format_data(self, data: dict) -> dict: 57 | """ 58 | Helper function for formatting data to match IBM expected input 59 | :param data: Data in dict to be formatted 60 | :return dict: Formatted data 61 | """ 62 | formatted_data = {} 63 | for ind, (key, values) in enumerate(data.items()): 64 | # Unpack outer list and extract values to variables 65 | (_, value), = values 66 | formatted_data[key] = value 67 | 68 | return formatted_data 69 | 70 | def publish_data(self, data) -> bool: 71 | wireless_controller, mqtt_communicator = utils.get_wifi_and_cloud_handlers( 72 | sync_time=False 73 | ) 74 | 75 | data = self._format_data(data) 76 | 77 | logging.debug("data to send = {}".format(data)) 78 | 79 | message_published = mqtt_communicator.publish_message( 80 | payload=data, topic=config.cfg.ibm_topic, qos=config.cfg.QOS 81 | ) 82 | if not message_published: 83 | for _ in range(3): 84 | message_published = mqtt_communicator.publish_message( 85 | payload=data, topic=config.cfg.ibm_topic, qos=config.cfg.QOS 86 | ) 87 | logging.debug( 88 | "Trying to send data again" 89 | ) 90 | if message_published: 91 | break 92 | else: 93 | logging.debug( 94 | "Tried to send data three times, failed! Aborting current measurement." 95 | ) 96 | mqtt_communicator.disconnect() 97 | wireless_controller.disconnect_station() 98 | return False 99 | 100 | mqtt_communicator.disconnect() 101 | wireless_controller.disconnect_station() 102 | return True -------------------------------------------------------------------------------- /MicroPython/src/cloud/cloud_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import machine 3 | 4 | from common import config, utils 5 | from communication.wirerless_connection_controller import get_wireless_connection_controller_instance 6 | from controller.main_controller_event import MainControllerEventType 7 | 8 | 9 | class Providers: 10 | AWS = "AWS" 11 | KAA = "KAA" 12 | THINGSBOARD = "THINGSBOARD" 13 | BLYNK = "BLYNK" 14 | IBM = "IBM" 15 | 16 | class CloudProvider: 17 | def device_configuration(self, wifi_credentials: list[dict]) -> int: 18 | """ 19 | Configures device in the cloud. Function used as hook to web_app. 20 | :param wifi_credentials: parameters to connect to wifi. 21 | :return: Error code (0 - OK, 1 - Error). 22 | """ 23 | logging.debug("Wifi access point configuration:") 24 | 25 | for access_point in wifi_credentials: 26 | logging.info("Ssid: {} Password: {}".format( 27 | access_point["ssid"], access_point["password"])) 28 | 29 | wireless_controller = get_wireless_connection_controller_instance() 30 | try: 31 | utils.connect_to_wifi(wireless_controller, wifi_credentials) 32 | logging.info(wireless_controller.sta_handler.ifconfig()) 33 | config.cfg.access_points = wifi_credentials 34 | self.configure_data() 35 | wireless_controller.disconnect_station() 36 | except Exception as e: 37 | logging.error("Exception caught: {}".format(e)) 38 | config.cfg.access_points = config.DEFAULT_ACCESS_POINTS 39 | config.cfg.save() 40 | return MainControllerEventType.ERROR_OCCURRED 41 | 42 | config.cfg.ap_config_done = True 43 | config.cfg.save() 44 | machine.reset() 45 | 46 | return 0 47 | 48 | def configure_data(self): 49 | pass 50 | 51 | def publish_data(self, data): 52 | """ 53 | Publish data to the cloud 54 | :param data: data to be sent to cloud 55 | :return: None 56 | """ 57 | pass 58 | -------------------------------------------------------------------------------- /MicroPython/src/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/src/common/__init__.py -------------------------------------------------------------------------------- /MicroPython/src/controller/main_controller_event.py: -------------------------------------------------------------------------------- 1 | class MainControllerEventType: 2 | CONFIGURE_ACCESS_POINT = 0 3 | TEST_CONNECTION = 3 4 | PRINT_TIME = 4 5 | GET_SENSOR_DATA = 5 6 | PUBLISH_DATA = 6 7 | GO_TO_SLEEP = 7 8 | ERROR_OCCURRED = 99 9 | 10 | 11 | class MainControllerEvent: 12 | def __init__(self, event_type: MainControllerEventType, callback=None, **data): 13 | self.event_type = event_type 14 | self.callback = callback 15 | self.data = data 16 | -------------------------------------------------------------------------------- /MicroPython/src/controller/main_controller_state.py: -------------------------------------------------------------------------------- 1 | class MainControllerStateName(): 2 | IDLE = 0 3 | ACCESS_POINT = 1 4 | DATA_ACQUISITION_RUNNING = 2 5 | DATA_ACQUISITION_PAUSED = 3 6 | ERROR = 4 7 | 8 | 9 | class MainControllerState(): 10 | def __init__(self): 11 | self.state = MainControllerStateName.IDLE 12 | 13 | def is_idle(self): 14 | return self.state == MainControllerStateName.IDLE 15 | 16 | def is_access_point(self): 17 | return self.state == MainControllerStateName.ACCESS_POINT 18 | 19 | def is_data_acquisition_running(self): 20 | return self.state == MainControllerStateName.DATA_ACQUISITION_RUNNING 21 | 22 | def is_data_acquisition_paused(self): 23 | return self.state == MainControllerStateName.DATA_ACQUISITION_PAUSED 24 | 25 | def is_error(self): 26 | return self.state == MainControllerStateName.ERROR 27 | 28 | def set_idle(self): 29 | if self.state != MainControllerStateName.ERROR: 30 | # log error 31 | return False 32 | self.state = MainControllerStateName.IDLE 33 | return True 34 | 35 | def set_access_point(self): 36 | if self.state != MainControllerStateName.IDLE: 37 | # log error 38 | return False 39 | self.state = MainControllerStateName.ACCESS_POINT 40 | return True 41 | 42 | def set_data_acquisition_running(self): 43 | self.state = MainControllerStateName.DATA_ACQUISITION_RUNNING 44 | 45 | def set_data_acquisition_paused(self): 46 | self.state = MainControllerStateName.DATA_ACQUISITION_PAUSED 47 | 48 | def set_error(self): 49 | self.state = MainControllerStateName.ERROR 50 | -------------------------------------------------------------------------------- /MicroPython/src/data_acquisition/data_acquisitor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from common import config, utils 4 | from peripherals.sensor import Sensor 5 | 6 | ADC_TO_VOLTS_ATTN_11DB = 0.000878906 7 | MAX_SAMPLES = 10 8 | 9 | 10 | class DataAcquisitor: 11 | """ 12 | Class to collect data from sensor 13 | """ 14 | 15 | def __init__(self): 16 | self.sensor = None 17 | self.data = {} 18 | 19 | self.sensor = Sensor(sensor_type=config.cfg.sensor_type, 20 | sensor_measurement_pin_number=config.cfg.sensor_measurement_pin, 21 | sensor_sda_pin_number=config.cfg.sensor_sda_pin, sensor_scl_pin_number=config.cfg.sensor_scl_pin, 22 | sensor_power_pin_number=config.cfg.sensor_power_pin) 23 | 24 | def acquire_temp_humi(self) -> dict: 25 | """ 26 | Get measurements of temperature and humidity. 27 | :return: Measurements in form of dict 28 | """ 29 | acquisition_timestamp = utils.get_current_timestamp_ms() 30 | self.sensor.turn_on() 31 | self.data = {} 32 | if self.sensor is not None: 33 | try: 34 | self.sensor.measure() 35 | self.data['temperature'] = [ 36 | [acquisition_timestamp, self.sensor.temperature()]] 37 | self.data['humidity'] = [ 38 | [acquisition_timestamp + 1, self.sensor.humidity()]] 39 | except OSError: 40 | logging.info("Error reading sensor!") 41 | 42 | self.sensor.turn_off() 43 | return self.data 44 | 45 | def get_single_measurement(self) -> int: 46 | """ 47 | Return single measurement of temperature. 48 | :return: Value of temperature. 49 | """ 50 | self.sensor.turn_on() 51 | if self.sensor is not None: 52 | try: 53 | self.sensor.measure() 54 | temperature = self.sensor.temperature() 55 | self.sensor.turn_off() 56 | return temperature 57 | except OSError: 58 | logging.error("Error reading sensor!") 59 | self.sensor.turn_off() 60 | return -1 61 | -------------------------------------------------------------------------------- /MicroPython/src/lib/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/micropython/micropython-lib/blob/master/logging/logging.py 3 | """ 4 | 5 | import sys 6 | 7 | from common import utils 8 | 9 | CRITICAL = 50 10 | ERROR = 40 11 | WARNING = 30 12 | INFO = 20 13 | DEBUG = 10 14 | NOTSET = 0 15 | 16 | _level_dict = { 17 | CRITICAL: "CRIT", 18 | ERROR: "ERROR", 19 | WARNING: "WARN", 20 | INFO: "INFO", 21 | DEBUG: "DEBUG", 22 | } 23 | 24 | _stream = sys.stderr 25 | 26 | class Logger: 27 | 28 | level = NOTSET 29 | 30 | def __init__(self, name): 31 | self.name = name 32 | if self.name: 33 | self.logger_name_str = '({})'.format(self.name) 34 | else: 35 | self.logger_name_str = '' 36 | 37 | def _level_str(self, level): 38 | l = _level_dict.get(level) 39 | if l is not None: 40 | return l 41 | return "LVL%s" % level 42 | 43 | def setLevel(self, level): 44 | self.level = level 45 | 46 | def isEnabledFor(self, level): 47 | return level >= (self.level or _level) 48 | 49 | def log(self, level, msg, *args): 50 | if level >= (self.level or _level): 51 | _stream.write("* %s [%s] %s\t" % (utils.get_current_timestamp_ms(), self._level_str(level), self.logger_name_str)) 52 | if not args: 53 | print(msg, file=_stream) 54 | else: 55 | print(msg % args, file=_stream) 56 | 57 | def debug(self, msg, *args): 58 | self.log(DEBUG, msg, *args) 59 | 60 | def info(self, msg, *args): 61 | self.log(INFO, msg, *args) 62 | 63 | def warning(self, msg, *args): 64 | self.log(WARNING, msg, *args) 65 | 66 | def error(self, msg, *args): 67 | self.log(ERROR, msg, *args) 68 | 69 | def critical(self, msg, *args): 70 | self.log(CRITICAL, msg, *args) 71 | 72 | def exc(self, e, msg, *args): 73 | self.log(ERROR, msg, *args) 74 | sys.print_exception(e, _stream) 75 | 76 | def exception(self, msg, *args): 77 | self.exc(sys.exc_info()[1], msg, *args) 78 | 79 | 80 | _level = INFO 81 | _loggers = {} 82 | 83 | def getLogger(name): 84 | if name in _loggers: 85 | return _loggers[name] 86 | l = Logger(name) 87 | _loggers[name] = l 88 | return l 89 | 90 | def info(msg, *args): 91 | getLogger(None).info(msg, *args) 92 | 93 | def debug(msg, *args): 94 | getLogger(None).debug(msg, *args) 95 | 96 | def warning(msg, *args): 97 | getLogger(None).warning(msg, *args) 98 | 99 | def error(msg, *args): 100 | getLogger(None).error(msg, *args) 101 | 102 | def exception(msg, *args): 103 | getLogger(None).exc(msg, *args) 104 | 105 | 106 | def basicConfig(level=INFO, filename=None, stream=None, format=None): 107 | global _level, _stream 108 | _level = level 109 | if stream: 110 | _stream = stream 111 | if filename is not None: 112 | print("logging.basicConfig: filename arg is not supported") 113 | if format is not None: 114 | print("logging.basicConfig: format arg is not supported") 115 | -------------------------------------------------------------------------------- /MicroPython/src/lib/picoweb/utils.py: -------------------------------------------------------------------------------- 1 | def unquote_plus(s): 2 | # TODO: optimize 3 | s = s.replace("+", " ") 4 | arr = s.split("%") 5 | arr2 = [chr(int(x[:2], 16)) + x[2:] for x in arr[1:]] 6 | return arr[0] + "".join(arr2) 7 | 8 | def parse_qs(s): 9 | res = {} 10 | if s: 11 | pairs = s.split("&") 12 | for p in pairs: 13 | vals = [unquote_plus(x) for x in p.split("=", 1)] 14 | if len(vals) == 1: 15 | vals.append(True) 16 | old = res.get(vals[0]) 17 | if old is not None: 18 | if not isinstance(old, list): 19 | old = [old] 20 | res[vals[0]] = old 21 | old.append(vals[1]) 22 | else: 23 | res[vals[0]] = vals[1] 24 | return res 25 | 26 | #print(parse_qs("foo")) 27 | #print(parse_qs("fo%41o+bar=+++1")) 28 | #print(parse_qs("foo=1&foo=2")) 29 | -------------------------------------------------------------------------------- /MicroPython/src/lib/pkg_resources.py: -------------------------------------------------------------------------------- 1 | import uio 2 | 3 | c = {} 4 | 5 | 6 | def resource_stream(package, resource): 7 | return open(resource) # hack, to get picoweb working 8 | 9 | if package not in c: 10 | try: 11 | if package: 12 | p = __import__(package + ".R", None, None, True) 13 | else: 14 | p = __import__("R") 15 | c[package] = p.R 16 | except ImportError: 17 | if package: 18 | p = __import__(package) 19 | d = p.__path__ 20 | else: 21 | d = "." 22 | # if d[0] != "/": 23 | # import uos 24 | # d = uos.getcwd() + "/" + d 25 | c[package] = d + "/" 26 | 27 | p = c[package] 28 | if isinstance(p, dict): 29 | return uio.BytesIO(p[resource]) 30 | return open(p + resource, "rb") 31 | -------------------------------------------------------------------------------- /MicroPython/src/lib/ulogging.py: -------------------------------------------------------------------------------- 1 | from logging import * -------------------------------------------------------------------------------- /MicroPython/src/main.py: -------------------------------------------------------------------------------- 1 | from machine import reset, reset_cause, wake_reason, HARD_RESET, PWRON_RESET, SOFT_RESET, PIN_WAKE, lightsleep 2 | import logging 3 | import _thread 4 | 5 | from controller.main_controller import MainController 6 | from controller.main_controller_event import MainControllerEventType, MainControllerEvent 7 | from common import config, utils 8 | 9 | 10 | def main(): 11 | logging.debug("=== MAIN START ===") 12 | 13 | # Increase stack size per thread this increases micropython recursion depth 14 | _thread.stack_size(8192 * 2) 15 | 16 | # Read and parse configuration from config.json 17 | utils.init() 18 | 19 | controller = MainController() 20 | 21 | # Check if configuration via access point has to be started 22 | if not config.cfg.ap_config_done or wake_reason() == PIN_WAKE: 23 | logging.debug("AP_DONE: {}, wake_reason: {}".format(config.cfg.ap_config_done, wake_reason())) 24 | logging.debug("Access points saved:") 25 | for ap in config.cfg.access_points: 26 | logging.debug("SSID: {} Password: {}".format(ap["ssid"], ap["password"])) 27 | if config.cfg.access_points != config.DEFAULT_ACCESS_POINTS: 28 | logging.debug("Access points aren't default. Try to connect") 29 | else: 30 | logging.debug("=== Entering configuration mode ===") 31 | 32 | event = MainControllerEvent(MainControllerEventType.CONFIGURE_ACCESS_POINT) 33 | controller.add_event(event) 34 | 35 | logging.debug("Main loop") 36 | # If the device is powered on, then actual time from NTP server must be downloaded 37 | 38 | if reset_cause() == HARD_RESET or reset_cause() == PWRON_RESET or reset_cause() == SOFT_RESET: 39 | event = MainControllerEvent(MainControllerEventType.TEST_CONNECTION) 40 | controller.add_event(event) 41 | 42 | # Print actual time 43 | event = MainControllerEvent(MainControllerEventType.PRINT_TIME) 44 | controller.add_event(event) 45 | 46 | # Read temperature and humidity from the sensor and return the data as JSON 47 | event = MainControllerEvent(MainControllerEventType.GET_SENSOR_DATA) 48 | controller.add_event(event) 49 | 50 | # Connect to WIFI and publish JSON with data to cloud via MQTT 51 | event = MainControllerEvent(MainControllerEventType.PUBLISH_DATA) 52 | controller.add_event(event) 53 | 54 | # Good night! 55 | event = MainControllerEvent(MainControllerEventType.GO_TO_SLEEP, callback=None, 56 | ms=config.cfg.data_publishing_period_in_ms) 57 | controller.add_event(event) 58 | 59 | controller.perform() 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /MicroPython/src/utime.py: -------------------------------------------------------------------------------- 1 | import time as org_time 2 | 3 | 4 | _t0 = org_time.time() 5 | 6 | 7 | def time(): 8 | return int(org_time.time() - _t0) 9 | 10 | 11 | def ticks_ms(): 12 | return int((org_time.time() - _t0) * 1000) 13 | 14 | 15 | def localtime(): 16 | pass 17 | 18 | def mktime(): 19 | pass 20 | 21 | 22 | def sleep(value): 23 | org_time.sleep(value) 24 | 25 | 26 | def sleep_ms(): 27 | pass 28 | 29 | 30 | def sleep_us(): 31 | pass 32 | 33 | 34 | def ticks_add(): 35 | pass 36 | 37 | 38 | def ticks_cpu(): 39 | pass 40 | 41 | 42 | def ticks_diff(): 43 | pass 44 | 45 | 46 | def ticks_us(): 47 | pass 48 | 49 | 50 | -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/add_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/src/web_server/web_pages/add_icon.png -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/delete_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/src/web_server/web_pages/delete_icon.png -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/favicon-32x32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/src/web_server/web_pages/favicon-32x32-1.png -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WizzDev Mobile IoT 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Wizzdev Mobile IoT 21 | 22 | 23 | 24 | Device Setup 25 | System Information 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | This website is hosted on ESP32 IoT Device. 37 | Click on "Device Setup" to configure device. 38 | You can also check some system information by 39 | clicking on "System Information" button. 40 | 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/logo_wizzdev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/MicroPython/src/web_server/web_pages/logo_wizzdev.png -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/logo_wizzdev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/server_info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WizzDev Mobile IoT 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Wizzdev Mobile IoT 21 | 22 | 23 | 24 | Device Setup 25 | System Information 26 | 27 | 28 | 29 | 30 | 31 | 32 | This feature isn't ready yet. It will be provided in future updates. 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /MicroPython/src/web_server/web_pages/setup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WizzDev Mobile IoT 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Wizzdev Mobile IoT 22 | 23 | 24 | 25 | Device Setup 26 | System Information 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Registered Devices: 35 | 36 | 37 | Loading 38 | . 39 | 46 | 47 | 48 | 49 | 50 | 51 | Register new device: 52 | 53 | 54 | Add new AP 55 | 56 | 57 | 58 | SSID: 59 | 60 | 61 | 62 | Password: 63 | 64 | 65 | 66 | Submit 67 | 68 | 69 | 70 | 71 | 72 | 77 | 78 | 79 | 80 | IoT Starter will now connect to the strongest AP available. 81 | 82 | 83 | 84 | Please enable javascript 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/dht.py: -------------------------------------------------------------------------------- 1 | class DHT11: 2 | def __init__(self, pin): 3 | pass 4 | 5 | def measure(self): 6 | pass 7 | 8 | def humidity(self): 9 | pass 10 | 11 | def temperature(self): 12 | pass 13 | 14 | 15 | class DHT22: 16 | def __init__(self, pin): 17 | pass 18 | 19 | def measure(self): 20 | pass 21 | 22 | def humidity(self): 23 | pass 24 | 25 | def temperature(self): 26 | pass 27 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/esp.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def osdebug(state): 4 | pass -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/esp32.py: -------------------------------------------------------------------------------- 1 | 2 | WAKEUP_ALL_LOW = 0 3 | WAKEUP_ANY_HIGH = 1 4 | 5 | 6 | def wake_on_touch(wake: bool): 7 | pass 8 | 9 | 10 | def wake_on_ext0(pin, level): 11 | pass 12 | 13 | 14 | def wake_on_ext1(pin,level): 15 | pass 16 | 17 | 18 | def raw_temperature() -> int: 19 | return 0 20 | 21 | 22 | def hall_sensor() -> int: 23 | return 0 24 | 25 | 26 | class Partition(): 27 | BOOT = 0 28 | RUNNING = 1 29 | TYPE_APP = 2 30 | TYPE_DATA = 3 31 | 32 | @classmethod 33 | def find(type=TYPE_APP, subtype=0xff, label=None): 34 | pass 35 | 36 | def info(self): 37 | pass 38 | 39 | def readblocks(self,block_num, buf, offset): 40 | pass 41 | 42 | def writeblocks(self,block_num, buf, offset): 43 | pass 44 | 45 | def ioctl(self, cmd, arg): 46 | pass 47 | 48 | def set_boot(self): 49 | pass 50 | 51 | def get_next_update(self): 52 | pass 53 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/machine.py: -------------------------------------------------------------------------------- 1 | # IRQ Wake Values 2 | IDLE = 0 3 | SLEEP = 1 4 | DEEPSLEEP = 2 5 | 6 | # Reset Causes 7 | PWRON_RESET = 0 8 | SOFT_RESET = 1 9 | HARD_RESET = 2 10 | WDT_RESET = 3 11 | DEEPSLEEP_RESET = 4 12 | 13 | 14 | # Wake-up Reasons 15 | EXT0_WAKE = 2 16 | EXT1_WAKE = 3 17 | PIN_WAKE = 2 18 | TIMER_WAKE = 4 19 | TOUCHPAD_WAKE = 5 20 | ULP_WAKE = 6 21 | 22 | 23 | class ADC: 24 | ATTN_0DB = 0 25 | ATTN_11DB = 1 26 | ATTN_2_5DB = 2 27 | ATTN_6DB = 3 28 | WIDTH_10BIT = 4 29 | WIDTH_11BIT = 5 30 | WIDTH_12BIT = 6 31 | WIDTH_9BIT = 7 32 | 33 | def __init__(self, pin): 34 | pass 35 | 36 | def read(self): 37 | return 1.65 38 | 39 | def atten(self, attenuation): 40 | pass 41 | 42 | def width(self, width): 43 | pass 44 | 45 | 46 | class Pin: 47 | IN = 0 48 | OUT = 1 49 | 50 | def __init__(self, pin_num, pin_dir=None): 51 | pass 52 | 53 | def on(self): 54 | pass 55 | 56 | def off(self): 57 | pass 58 | 59 | 60 | class SDCard: 61 | def __init__(self, slot, width=1): 62 | pass 63 | 64 | 65 | class Timer: 66 | def __init__(self, id): 67 | pass 68 | 69 | 70 | def deepsleep(time_to_sleep): 71 | pass 72 | 73 | 74 | def lightsleep(time_to_sleep): 75 | pass 76 | 77 | 78 | def reset_cause(): 79 | return 0 80 | 81 | 82 | def wake_reason(): 83 | return 0 84 | 85 | 86 | def unique_id(): 87 | return 0 88 | 89 | 90 | def reset(): 91 | pass -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/network.py: -------------------------------------------------------------------------------- 1 | STA_IF = 0 2 | AP_IF = ... 3 | 4 | class WLAN: 5 | 6 | 7 | def __init__(self, mode): 8 | self.activeated = False 9 | self.connected = False 10 | pass 11 | 12 | def active(self, active:bool): 13 | self.activeated = active 14 | return self.activeated 15 | 16 | def isconnected(self): 17 | return self.connected 18 | 19 | def connect(self, ssid, pwd): 20 | import time 21 | time.sleep(3) 22 | self.connected = True 23 | 24 | def disconnect(self): 25 | self.connected = False 26 | 27 | def ifconfig(self): 28 | return "0.0.0.0, 0.0.0.0, 0.0.0.0" 29 | 30 | def config(self, essid, password): 31 | pass 32 | 33 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/ntptime.py: -------------------------------------------------------------------------------- 1 | 2 | def settime(): 3 | pass 4 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/ujson.py: -------------------------------------------------------------------------------- 1 | from json import * -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/umqtt/simple.py: -------------------------------------------------------------------------------- 1 | 2 | class MQTTClient: 3 | def __init__(self, client_id, server, port=0, user=None, password=None, keepalive=0, 4 | ssl=False, ssl_params={}): 5 | self.server = server 6 | self.port = port 7 | 8 | def _send_str(self, s): 9 | pass 10 | 11 | def _recv_len(self): 12 | pass 13 | 14 | def set_callback(self, f): 15 | pass 16 | 17 | def set_last_will(self, topic, msg, retain=False, qos=0): 18 | pass 19 | 20 | def connect(self, clean_session=True): 21 | pass 22 | 23 | def disconnect(self): 24 | pass 25 | 26 | def ping(self): 27 | pass 28 | 29 | def publish(self, topic, msg, retain=False, qos=0): 30 | pass 31 | 32 | def subscribe(self, topic, qos=0): 33 | pass 34 | 35 | def wait_msg(self): 36 | pass 37 | 38 | def check_msg(self): 39 | pass 40 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/uos.py: -------------------------------------------------------------------------------- 1 | def statvfs(path): 2 | return (4096, 4096, 506, 493, 493, 0, 0, 0, 0, 255) 3 | 4 | 5 | def mount(storage_block, location): 6 | pass 7 | 8 | 9 | def umount(location): 10 | pass 11 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/ure.py: -------------------------------------------------------------------------------- 1 | from re import * 2 | -------------------------------------------------------------------------------- /MicroPython/ulib_mocks/utime.py: -------------------------------------------------------------------------------- 1 | import time as org_time 2 | 3 | 4 | _t0 = org_time.time() 5 | 6 | 7 | def time(): 8 | return int(org_time.time() - _t0) 9 | 10 | 11 | def ticks_ms(): 12 | return int((org_time.time() - _t0) * 1000) 13 | 14 | 15 | def localtime(): 16 | pass 17 | 18 | def mktime(): 19 | pass 20 | 21 | 22 | def sleep(value): 23 | org_time.sleep(value) 24 | 25 | 26 | def sleep_ms(): 27 | pass 28 | 29 | 30 | def sleep_us(): 31 | pass 32 | 33 | 34 | def ticks_add(): 35 | pass 36 | 37 | 38 | def ticks_cpu(): 39 | pass 40 | 41 | 42 | def ticks_diff(): 43 | pass 44 | 45 | 46 | def ticks_us(): 47 | pass 48 | 49 | 50 | -------------------------------------------------------------------------------- /ThingsBoard/README.md: -------------------------------------------------------------------------------- 1 | # Set up ThingsBoard locally on your computer 2 | The instruction walks you through the process of creating your own IoT device in just a few steps. 3 | 4 | An example dashboard that might be created can look like this: 5 |  6 | 7 | ## What is ThingsBoard 8 | [ThingsBoard](https://thingsboard.io/) is an open-source IoT platform that enables rapid development, management, and scaling of IoT projects. You can host it locally on your device or use the paid cloud version. 9 | 10 | ## Installation 11 | 12 | ### Install requirements 13 | 14 | After you've created virtual environment, your current directory should be "iot-starter" 15 | 16 | ``` 17 | pip install -r ThingsBoard/requirements.txt 18 | ``` 19 | 20 | ## Set up local ThingsBoard server 21 | 22 | ### 1. Installation 23 | 24 | ## Linux 25 | To create your own ThingsBoard server on Linux (Ubuntu), just follow this [instruction](https://thingsboard.io/docs/user-guide/install/ubuntu/). 26 | 27 | ## Windows 28 | For Windows users, instruction can be found [here](https://thingsboard.io/docs/user-guide/install/windows/). 29 | 30 | **NOTE** 31 | 32 | During installation of Java 11 you may encounter a problem with finding an installation path. If you are unsure of where the java was installed, please run in the cmd following command: 33 | ``` 34 | echo %JAVA_HOME% 35 | ``` 36 | It will print you the java home path where you need to copy downloaded PostgreSQL JDBC Driver. 37 | 38 | ### 2. Device profile set up 39 | 40 | Once you finally have your ThingsBoard ready, open your browser and navigate to server webpage. Default link for ThingsBoard installed on local machine is: 41 | ``` 42 | http://localhost:8080/ 43 | ``` 44 | In case you set up cloud on some other device in your local network, please remember to adjust the address. For example, if the computer you installed ThingsBoard on has ip address: 192.168.1.21, then your link for accessing the web page would be: 45 | ``` 46 | http://192.168.1.21:8080/ 47 | ``` 48 | Now go ahead and log in to your server. If you have specified *-loadDemo* during execution of the installation script, the default credentials are: 49 | * System Administrator: sysadmin@thingsboard.org / sysadmin 50 | * Tenant Administrator: tenant@thingsboard.org / tenant 51 | * Customer User: customer@thingsboard.org / customer 52 | 53 | You can always change password for each account in the account profile page. 54 | Please log in to **Tenant Administration** account. 55 | 56 | Now the only thing you need to do is to obtain two keys needed for device registering. To do this, click on the **Device profiles** tab on the left and hit the profile name you want to use (you can use the default or create a new one). Toggle on edit mode, then go to the **Device provisioning** tab and change the **Provision strategy** to 'Allow to create new devices'. After applying changes, two new fields should appear - **Provision device key** and **Provision device secret**. Please copy them and save for later. 57 | 58 | ### 3. Open ports 59 | 60 | To send telemetry data directly from your DevBoard to local server on your computer, you need to open two communication ports. 61 | 62 | * Linux: 63 | ``` 64 | sudo ufw allow 8080 65 | sudo ufw allow 1883 66 | ``` 67 | * Windows 68 | 69 | To open necessary ports on Windows, please refer to this [link](https://thingsboard.io/docs/user-guide/install/windows/#windows-firewall-settings). 70 | -------------------------------------------------------------------------------- /ThingsBoard/ThingsBoard_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/ThingsBoard/ThingsBoard_dashboard.png -------------------------------------------------------------------------------- /ThingsBoard/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./../MicroPython/requirements.txt -------------------------------------------------------------------------------- /db_access/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,pycharm 3 | # Edit at https://www.gitignore.io/?templates=python,pycharm 4 | 5 | ### PyCharm ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### PyCharm Patch ### 76 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 77 | 78 | # *.iml 79 | # modules.xml 80 | # .idea/misc.xml 81 | # *.ipr 82 | 83 | # Sonarlint plugin 84 | .idea/**/sonarlint/ 85 | 86 | # SonarQube Plugin 87 | .idea/**/sonarIssues.xml 88 | 89 | # Markdown Navigator plugin 90 | .idea/**/markdown-navigator.xml 91 | .idea/**/markdown-navigator/ 92 | 93 | ### Python ### 94 | # Byte-compiled / optimized / DLL files 95 | __pycache__/ 96 | *.py[cod] 97 | *$py.class 98 | 99 | # C extensions 100 | *.so 101 | 102 | # Distribution / packaging 103 | .Python 104 | build/ 105 | develop-eggs/ 106 | dist/ 107 | downloads/ 108 | eggs/ 109 | .eggs/ 110 | lib/ 111 | lib64/ 112 | parts/ 113 | sdist/ 114 | var/ 115 | wheels/ 116 | pip-wheel-metadata/ 117 | share/python-wheels/ 118 | *.egg-info/ 119 | .installed.cfg 120 | *.egg 121 | MANIFEST 122 | 123 | # PyInstaller 124 | # Usually these files are written by a python script from a template 125 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 126 | *.manifest 127 | *.spec 128 | 129 | # Installer logs 130 | pip-log.txt 131 | pip-delete-this-directory.txt 132 | 133 | # Unit test / coverage reports 134 | htmlcov/ 135 | .tox/ 136 | .nox/ 137 | .coverage 138 | .coverage.* 139 | .cache 140 | nosetests.xml 141 | coverage.xml 142 | *.cover 143 | .hypothesis/ 144 | .pytest_cache/ 145 | 146 | # Translations 147 | *.mo 148 | *.pot 149 | 150 | # Scrapy stuff: 151 | .scrapy 152 | 153 | # Sphinx documentation 154 | docs/_build/ 155 | 156 | # PyBuilder 157 | target/ 158 | 159 | # pyenv 160 | .python-version 161 | 162 | # pipenv 163 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 164 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 165 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 166 | # install all needed dependencies. 167 | #Pipfile.lock 168 | 169 | # celery beat schedule file 170 | celerybeat-schedule 171 | 172 | # SageMath parsed files 173 | *.sage.py 174 | 175 | # Spyder project settings 176 | .spyderproject 177 | .spyproject 178 | 179 | # Rope project settings 180 | .ropeproject 181 | 182 | # Mr Developer 183 | .mr.developer.cfg 184 | .project 185 | .pydevproject 186 | 187 | # mkdocs documentation 188 | /site 189 | 190 | # mypy 191 | .mypy_cache/ 192 | .dmypy.json 193 | dmypy.json 194 | 195 | # Pyre type checker 196 | .pyre/ 197 | 198 | # End of https://www.gitignore.io/api/python,pycharm 199 | 200 | .idea/ 201 | venv/ 202 | -------------------------------------------------------------------------------- /db_access/README.md: -------------------------------------------------------------------------------- 1 | # db_access 2 | library to access IoT database, with devices and measurements. 3 | Library allows adding, managing and retrieving data from database with convenient functions. 4 | 5 | 6 | ### Setup 7 | Create virtual environment and install requirements.txt 8 | 9 | ### Configure db_access 10 | Library can be configured with following system environments: 11 | * **IOT_AWS_REGION** region, where are DynamoDB tables 12 | * **DATABASE_PREFIX** prefix added to all DynamoDB tables 13 | * **DATABASE_HOST** you can setup it to localhost:port to use Dynalite instead of AWS DynamoDB 14 | * **DEBUG** add "_dev" suffix for all DynamoDB tables 15 | 16 | ### AWS account configuration 17 | Type in the terminal: 18 | ``` bash 19 | aws configure 20 | ``` 21 | and enter the following configuration when prompted by aws cli: 22 | ```bash 23 | AWS Access Key ID [None]: yyyy 24 | AWS Secret Access Key [None]: xxxx 25 | Default region name [None]: eu-west-2 26 | Default output format [None]: 27 | ``` 28 | 29 | 30 | -------------------------------------------------------------------------------- /db_access/__init__.py: -------------------------------------------------------------------------------- 1 | from .model.base_model import Model 2 | from .model.base_model import AuditModel 3 | from .model.measurement_model import MeasurementModel 4 | from .model.measurement_model import MeasurementTypeModel 5 | from .model.device_model import DeviceModel 6 | from .model.device_model import DeviceGroupModel 7 | from .model.device_model import DeviceTypeModel 8 | 9 | from .service.base_service import BaseService 10 | from .service.device_service import DeviceService 11 | from .service.device_service import DeviceGroupService 12 | from .service.device_service import DeviceTypeService 13 | from .service.measurement_service import MeasurementService 14 | from .service.measurement_service import MeasurementTypeService 15 | 16 | from .common.config import * 17 | from .common.errors import * 18 | from .common.util import * 19 | 20 | # Create tables 21 | create_table(MeasurementModel) 22 | create_table(MeasurementTypeModel) 23 | create_table(DeviceModel) 24 | create_table(DeviceGroupModel) 25 | create_table(DeviceTypeModel) 26 | -------------------------------------------------------------------------------- /db_access/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/db_access/common/__init__.py -------------------------------------------------------------------------------- /db_access/common/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | IOT_AWS_REGION = os.environ.get('IOT_AWS_REGION', 'eu-west-2') 4 | 5 | DATABASE_PREFIX = os.environ.get('DATABASE_PREFIX', 'db') # Used in process of DynamoDB generating tables name 6 | DATABASE_HOST = os.environ.get('DATABASE_HOST', None) # Add host if want local connection 7 | 8 | DEBUG = bool(os.environ.get('DEBUG', False)) 9 | -------------------------------------------------------------------------------- /db_access/common/errors.py: -------------------------------------------------------------------------------- 1 | class IotDbBaseException(Exception): 2 | pass 3 | 4 | 5 | class ItemNotUnique(IotDbBaseException): 6 | pass 7 | 8 | 9 | class ItemDoesNotExist(IotDbBaseException): 10 | pass 11 | -------------------------------------------------------------------------------- /db_access/common/util.py: -------------------------------------------------------------------------------- 1 | import time 2 | import typing as t 3 | 4 | import pynamodb.models 5 | 6 | from common.config import DATABASE_PREFIX, DEBUG 7 | 8 | 9 | def get_timestamp() -> int: 10 | return int(round(time.time() * 1000)) 11 | 12 | 13 | def generate_table_name(basename: str) -> str: 14 | table_name = f"{DATABASE_PREFIX}_{basename}" 15 | if DEBUG: 16 | table_name += "_dev" 17 | return table_name 18 | 19 | 20 | def generate_label(name: str) -> str: 21 | """ Creates nice looking string from any string provided in input """ 22 | return " ".join([s.capitalize() for s in name.split("_")]) 23 | 24 | 25 | def create_table(model_class: t.Type[pynamodb.models.Model]): 26 | model_class.Meta.billing_mode = 'PAY_PER_REQUEST' # Enables on demand capacity 27 | model_class.create_table(wait=True, read_capacity_units=1, write_capacity_units=1) -------------------------------------------------------------------------------- /db_access/example_1.py: -------------------------------------------------------------------------------- 1 | """ A simple example demonstrating basic library usage: 2 | - how to create a device (in database, don't confuse with creating 'thing' with certifivates) 3 | - how to add measurements 4 | - how to obtain measurements for devices 5 | """ 6 | 7 | import time 8 | 9 | from service.device_service import DeviceService 10 | from service.measurement_service import MeasurementService 11 | from view.device_view import DeviceView 12 | 13 | if not DeviceService.check_if_device_exists('dev_p_1'): 14 | DeviceService.create_device(device_id='dev_p_1', description='long description of dev_p_1') 15 | for i in range(5): 16 | MeasurementService.create_measurement(device_id='dev_p_1', value=4 + i + i % 3 - i // 5) 17 | time.sleep(0.1) 18 | else: 19 | print(f'dev_p_1 device timestamp: {DeviceService.get("dev_p_1").creation_timestamp}') 20 | 21 | 22 | if not DeviceService.check_if_device_exists('dev_p_2'): 23 | DeviceService.create_device(device_id='dev_p_2', description='long description of dev_p_2') 24 | for i in range(5): 25 | MeasurementService.create_measurement(device_id='dev_p_2', value=2100 + i) 26 | time.sleep(0.1) 27 | else: 28 | print(f'dev_p_2 device timestamp: {DeviceService.get("dev_p_2").creation_timestamp}') 29 | 30 | 31 | if not DeviceService.check_if_device_exists('dev_p_3'): 32 | DeviceService.create_device(device_id='dev_p_3', description='long description of dev_p_3') 33 | else: 34 | print(f'dev_p_3 device timestamp: {DeviceService.get("dev_p_3").creation_timestamp}') 35 | 36 | 37 | 38 | # get some data 39 | devices_txt = [d.device_id for d in DeviceService.get_all()] 40 | print(f'Device list: {devices_txt}') 41 | 42 | newest_measurements_2 = DeviceView.get_newest_measurements_for_device(device_id='dev_p_2') 43 | print(f'newest_measurements for device dev_p_2: {newest_measurements_2}') 44 | 45 | dev_with_newest_measurements = DeviceView.get_all_devices_with_last_measurement() 46 | txt = [f'device {device_id}: {value}' for device_id, value in dev_with_newest_measurements.items()] 47 | print(f'newest_measurements for devices: {txt}') 48 | -------------------------------------------------------------------------------- /db_access/example_2.py: -------------------------------------------------------------------------------- 1 | """ A simple example how to read measurements from database and visualize them 2 | 3 | Additional requirement for visualization - matplotlib 4 | pip install matplotlib 5 | sudo apt-get install python3-tk 6 | """ 7 | 8 | from datetime import datetime 9 | 10 | import matplotlib.pyplot as plt 11 | 12 | from view.device_view import DeviceView 13 | 14 | device_name = 'dev_p_1' 15 | 16 | newest_measurements = DeviceView.get_newest_measurements_for_device(device_id=device_name, max_number_of_measurements=15) 17 | timestamps = [] 18 | vals = [] 19 | 20 | for measurement in newest_measurements: 21 | timestamps.append(measurement.timestamp / 1000) 22 | vals.append(measurement.value) 23 | 24 | dates = [datetime.fromtimestamp(t).strftime("%H:%M:%S") for t in timestamps] 25 | 26 | fig, ax = plt.subplots() 27 | ax.plot(timestamps, vals, '-o') 28 | 29 | ax.set(xlabel='Time', ylabel='Temperature (K)', 30 | title=f'Temperature for device "{device_name}"') 31 | ax.set_xticklabels(dates) 32 | ax.grid() 33 | plt.xticks(rotation=70) 34 | fig.savefig("test.png") 35 | plt.show() 36 | -------------------------------------------------------------------------------- /db_access/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/db_access/model/__init__.py -------------------------------------------------------------------------------- /db_access/model/base_model.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pynamodb.models 4 | import pynamodb.attributes as attributes 5 | 6 | from common.util import get_timestamp 7 | 8 | 9 | class Model(pynamodb.models.Model): 10 | class Meta: 11 | abstract = True 12 | 13 | 14 | class AuditModel(Model): 15 | class Meta: 16 | abstract = True 17 | 18 | is_removed = attributes.BooleanAttribute(default=False) 19 | created_at = attributes.NumberAttribute(default=get_timestamp()) 20 | modified_at = attributes.NumberAttribute(default=get_timestamp()) 21 | 22 | def save(self, condition=None) -> t.Dict[str, t.Any]: 23 | self.modified_at = get_timestamp() 24 | return super().save(condition) 25 | 26 | def safe_delete(self): 27 | self.is_removed = True 28 | return self.save() 29 | -------------------------------------------------------------------------------- /db_access/model/device_model.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from pynamodb.attributes import UnicodeAttribute, JSONAttribute 4 | 5 | from common.config import IOT_AWS_REGION, DATABASE_HOST 6 | from common.util import generate_table_name, create_table 7 | from model.base_model import AuditModel 8 | 9 | 10 | class DeviceTypeModel(AuditModel): 11 | class Meta: 12 | table_name = generate_table_name("iot_device_types") 13 | region = IOT_AWS_REGION 14 | host = DATABASE_HOST 15 | 16 | device_type = UnicodeAttribute(hash_key=True) 17 | description = UnicodeAttribute(null=True) 18 | 19 | 20 | class DeviceGroupModel(AuditModel): 21 | class Meta: 22 | table_name = generate_table_name("iot_device_groups") 23 | region = IOT_AWS_REGION 24 | host = DATABASE_HOST 25 | 26 | device_group = UnicodeAttribute(hash_key=True) 27 | description = UnicodeAttribute(null=True) 28 | 29 | 30 | class DeviceModel(AuditModel): 31 | class Meta: 32 | table_name = generate_table_name("iot_devices") 33 | region = IOT_AWS_REGION 34 | host = DATABASE_HOST 35 | 36 | device_id = UnicodeAttribute(hash_key=True) 37 | description = UnicodeAttribute(null=True) 38 | device_type = UnicodeAttribute(default="default") 39 | device_group = UnicodeAttribute(default="default") 40 | settings = JSONAttribute(default={}) 41 | -------------------------------------------------------------------------------- /db_access/model/measurement_model.py: -------------------------------------------------------------------------------- 1 | from pynamodb.attributes import UnicodeAttribute, NumberAttribute 2 | 3 | from common.config import IOT_AWS_REGION, DATABASE_HOST 4 | from common.util import generate_table_name, create_table 5 | from model.base_model import AuditModel, Model 6 | 7 | 8 | class MeasurementTypeModel(AuditModel): 9 | class Meta: 10 | table_name = generate_table_name("iot_measurement_types") 11 | region = IOT_AWS_REGION 12 | host = DATABASE_HOST 13 | 14 | name = UnicodeAttribute(hash_key=True) 15 | label = UnicodeAttribute() 16 | unit = UnicodeAttribute(default="", null=True) 17 | description = UnicodeAttribute(null=True) 18 | priority = NumberAttribute(null=True, default=0) 19 | 20 | 21 | class MeasurementModel(Model): 22 | class Meta: 23 | table_name = generate_table_name("iot_measurements") 24 | region = IOT_AWS_REGION 25 | host = DATABASE_HOST 26 | 27 | device_id = UnicodeAttribute(hash_key=True) 28 | timestamp = NumberAttribute(range_key=True) 29 | measurement_type = UnicodeAttribute() 30 | value = NumberAttribute() 31 | -------------------------------------------------------------------------------- /db_access/model/user_model.py: -------------------------------------------------------------------------------- 1 | from pynamodb.attributes import UnicodeAttribute 2 | 3 | from common.config import IOT_AWS_REGION, DATABASE_HOST 4 | from common.util import generate_table_name 5 | from model.base_model import AuditModel 6 | 7 | 8 | class UserLoginModel(AuditModel): 9 | class Meta: 10 | table_name = generate_table_name("iot_users") 11 | region = IOT_AWS_REGION 12 | host = DATABASE_HOST 13 | 14 | username = UnicodeAttribute(hash_key=True) 15 | password = UnicodeAttribute() 16 | -------------------------------------------------------------------------------- /db_access/requirements.txt: -------------------------------------------------------------------------------- 1 | pynamodb==4.3.3 2 | awscli==1.18.17 3 | -------------------------------------------------------------------------------- /db_access/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/db_access/service/__init__.py -------------------------------------------------------------------------------- /db_access/service/base_service.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | import pynamodb 4 | import pynamodb.exceptions 5 | 6 | from model.base_model import Model 7 | from common.errors import ItemNotUnique 8 | 9 | 10 | class BaseService: 11 | model_class = None # type: Type[Model] 12 | 13 | @classmethod 14 | def get(cls, hash_key, range_key=None): 15 | return cls.model_class.get(hash_key=hash_key, range_key=range_key) 16 | 17 | @classmethod 18 | def put(cls, **kwargs): 19 | item = cls.model_class(**kwargs) 20 | item.save() 21 | return item 22 | 23 | @classmethod 24 | def create_with_condition(cls, condition, error_message='', **kwargs): 25 | try: 26 | item = cls.model_class(**kwargs) 27 | item.save(condition=condition) 28 | return item 29 | except pynamodb.exceptions.PutError as e: 30 | if e.cause_response_code == 'ConditionalCheckFailedException': 31 | if not error_message: 32 | error_message = f'Condition: "{condition}"' 33 | raise ItemNotUnique(error_message) from e 34 | else: 35 | raise 36 | 37 | @classmethod 38 | def get_all(cls): 39 | items_iterator = cls.model_class.scan() 40 | return [i for i in items_iterator] 41 | 42 | @classmethod 43 | def check_if_exists(cls, hash_key, range_key=None): 44 | try: 45 | cls.model_class.get(hash_key=hash_key, range_key=range_key) 46 | return True 47 | except pynamodb.exceptions.DoesNotExist: 48 | return False 49 | 50 | @classmethod 51 | def query(cls, *args, **kwargs): 52 | return cls.model_class.query(*args, **kwargs) 53 | 54 | @classmethod 55 | def get_latest(cls, hash_key, reverse=True, range_key_condition=None, limit=5): 56 | items_iterator = cls.model_class.query( 57 | hash_key=hash_key, 58 | range_key_condition=range_key_condition, 59 | scan_index_forward=False, 60 | limit=limit) 61 | items_list = [item for item in items_iterator] 62 | if reverse: 63 | items_list = items_list[::-1] 64 | return items_list 65 | 66 | @classmethod 67 | def scan(cls, reverse=True, **kwargs): 68 | items_iterator = cls.model_class.scan(**kwargs) 69 | return [item for item in items_iterator][::-1] 70 | 71 | @classmethod 72 | def write_batch(cls, items_data: List[dict]): 73 | """ Create multiple items in batch operation, but without condition checking """ 74 | with cls.model_class.batch_write() as batch: 75 | items = [cls.model_class(**d) for d in items_data] 76 | for item in items: 77 | batch.save(item) 78 | -------------------------------------------------------------------------------- /db_access/service/measurement_service.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import pynamodb.exceptions 4 | 5 | import common.errors 6 | from common.util import get_timestamp, generate_label 7 | from service.base_service import BaseService 8 | from model.measurement_model import MeasurementModel, MeasurementTypeModel 9 | 10 | 11 | class MeasurementService(BaseService): 12 | model_class = MeasurementModel 13 | 14 | @classmethod 15 | def create_measurement(cls, device_id: str, value: int or float, measurement_type: str, 16 | timestamp: int = None) -> MeasurementModel: 17 | result = cls.create_with_condition( 18 | condition=MeasurementModel.timestamp.does_not_exist(), 19 | error_message=f'Measurement with specified timestamp ({timestamp})' 20 | f' already exists for device with id "{device_id}"!', 21 | device_id=device_id, 22 | value=value, 23 | measurement_type=measurement_type, 24 | timestamp=timestamp or get_timestamp(), 25 | ) 26 | MeasurementTypeService.create_measurement_type_if_not_exist( 27 | name=measurement_type, 28 | label=generate_label(measurement_type), 29 | description="Created automatically during adding measurements" 30 | ) 31 | return result 32 | 33 | @classmethod 34 | def create_measurements(cls, measurements: t.List[dict]): 35 | cls.write_batch(measurements) 36 | measurements_types = set([measurement.get('measurement_type') for measurement in measurements]) 37 | for measurement_type in measurements_types: 38 | MeasurementTypeService.create_measurement_type_if_not_exist( 39 | name=measurement_type, 40 | label=generate_label(measurement_type), 41 | description="Created automatically during adding measurements" 42 | ) 43 | 44 | 45 | class MeasurementTypeService(BaseService): 46 | model_class = MeasurementTypeModel 47 | 48 | @classmethod 49 | def create_measurement_type(cls, 50 | name: str, 51 | label: str = None, 52 | description: str = None, 53 | unit: str = "", 54 | priority: int = 0) -> MeasurementTypeModel: 55 | return cls.create_with_condition( 56 | name=name, 57 | label=label, 58 | description=description, 59 | unit=unit, 60 | priority=priority, 61 | condition=cls.model_class.name.does_not_exist(), 62 | error_message=f'Measurement Type with specified id ("{name}") already exists!' 63 | ) 64 | 65 | @classmethod 66 | def create_measurement_type_if_not_exist(cls, 67 | name: str, 68 | label: str = None, 69 | description: str = None, 70 | unit: str = "", 71 | priority: int = 0) -> MeasurementTypeModel: 72 | try: 73 | return cls.create_measurement_type(name=name, label=label or generate_label(name), 74 | description=description, priority=priority, unit=unit) 75 | except (pynamodb.exceptions.PutError, common.errors.ItemNotUnique): 76 | pass 77 | -------------------------------------------------------------------------------- /db_access/view/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/db_access/view/__init__.py -------------------------------------------------------------------------------- /db_access/view/base_view.py: -------------------------------------------------------------------------------- 1 | class BaseView: 2 | pass 3 | -------------------------------------------------------------------------------- /db_access/view/device_view.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union, List 2 | 3 | from common.errors import ItemDoesNotExist 4 | from model.measurement_model import MeasurementModel 5 | from service.device_service import DeviceService 6 | from service.measurement_service import MeasurementService 7 | from view.base_view import BaseView 8 | 9 | 10 | class DeviceView(BaseView): 11 | 12 | @classmethod 13 | def get_all_devices_with_last_measurement(cls) -> Dict[str, Union[float, None]]: 14 | measurements_with_value = {} 15 | for name, measurement in cls.get_all_devices_with_last_measurement_and_time().items(): 16 | measurements_with_value[name] = measurement.value if measurement else None 17 | return measurements_with_value 18 | 19 | @classmethod 20 | def get_all_devices_with_last_measurement_and_time(cls) -> Dict[str, Union[MeasurementModel, None]]: 21 | devices_with_measurement = {} 22 | devices = DeviceService.get_all() 23 | for device_id in devices: 24 | device_id = device_id.device_id 25 | newest_measurements = cls.get_newest_measurements_for_device( 26 | device_id=device_id, 27 | max_number_of_measurements=1) 28 | if not newest_measurements: 29 | newest_measurement_value = None 30 | else: 31 | newest_measurement_value = newest_measurements[0] 32 | devices_with_measurement[device_id] = newest_measurement_value 33 | return devices_with_measurement 34 | 35 | @classmethod 36 | def get_newest_measurements_for_device(cls, device_id, max_number_of_measurements=3) -> List[MeasurementModel]: 37 | if not DeviceService.check_if_device_exists(device_id=device_id): 38 | raise ItemDoesNotExist(f'Device with ID "{device_id}" does not exist!') 39 | 40 | return MeasurementService.get_latest(hash_key=device_id, limit=max_number_of_measurements) 41 | 42 | @staticmethod 43 | def get_measurements_for_device_for_time_range(device_id, 44 | start_timestamp, 45 | end_timestamp, 46 | max_number_of_measurements=10): 47 | return MeasurementService.scan( 48 | limit=max_number_of_measurements, 49 | filter_condition=( 50 | (MeasurementModel.device_id == device_id) 51 | & 52 | (MeasurementModel.timestamp.between(start_timestamp, end_timestamp)) 53 | )) 54 | -------------------------------------------------------------------------------- /db_access/view/measurement_view.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/db_access/view/measurement_view.py -------------------------------------------------------------------------------- /lamba_health_check/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/lamba_health_check/__init__.py -------------------------------------------------------------------------------- /lamba_health_check/checks.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import requests 4 | 5 | from db_access import MeasurementService, DeviceService 6 | 7 | 8 | FOUR_HOURS_AGO = time.time() - (60 * 60 * 4) 9 | FOUR_DAYS_AGO = time.time() - (60 * 60 * 24 * 4) 10 | 11 | 12 | def was_any_measurement_sent_recently(break_point_timestamp: float = FOUR_HOURS_AGO): 13 | measurements = MeasurementService.model_class.scan(limit=1) 14 | measurement = measurements.next() 15 | return measurement.timestamp > break_point_timestamp 16 | 17 | def is_visualization_okay(url: str): 18 | with requests.get(url) as response: 19 | return response.status_code == 200 -------------------------------------------------------------------------------- /lamba_health_check/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import datetime 3 | import os 4 | 5 | import boto3 6 | 7 | from checks import is_any_battery_status_was_sent_recently, is_any_measurement_was_sent_recently, is_visualization_okay 8 | 9 | 10 | def main(*args, **kwargs): 11 | logging.info("Datetime: ", datetime.datetime.utcnow().strftime("%d/%m/%y %H:%M:%S")) 12 | if not is_any_battery_status_was_sent_recently(): 13 | raise Exception("Check failed: " + is_any_battery_status_was_sent_recently.__name__) 14 | if not is_any_measurement_was_sent_recently(): 15 | raise Exception("Check failed: " + is_any_measurement_was_sent_recently.__name__) 16 | if not is_visualization_okay("https://iot-demo.wizzdev.pl"): 17 | raise Exception("Check failed: " + is_visualization_okay.__name__) 18 | -------------------------------------------------------------------------------- /lamba_health_check/requirements.txt: -------------------------------------------------------------------------------- 1 | sentry_sdk==0.17.4 2 | requests==2.24.0 -------------------------------------------------------------------------------- /lambda_collect_measurements/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/lambda_collect_measurements/__init__.py -------------------------------------------------------------------------------- /lambda_collect_measurements/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import sentry_sdk 5 | 6 | from db_access.service.measurement_service import MeasurementService 7 | 8 | 9 | def main(*args, **kwargs): 10 | # init sentry 11 | sentry_dsn = os.environ.get('SENTRY') 12 | if sentry_dsn: 13 | sentry_sdk.init(sentry_dsn, environment=os.environ.get("MODE", "undefined")) 14 | sentry_sdk.add_breadcrumb(args[0]) 15 | 16 | payload = args[0] 17 | device_id = payload['client_id'] 18 | 19 | # Add measurements to database 20 | for measurement_type, measurement_raws in payload['data'].items(): 21 | measurements = [] 22 | try: 23 | for timestamp, value in measurement_raws: 24 | measurements.append({ 25 | 'device_id': device_id, 26 | 'timestamp': timestamp, 27 | 'value': value, 28 | 'measurement_type': measurement_type 29 | }) 30 | MeasurementService.create_measurements(measurements) 31 | except Exception as exception: 32 | logging.exception(f"Exception occurred while adding measurements to database: {exception} " 33 | f"\n Inputs: {measurement_type}, {measurement_raws}") 34 | sentry_sdk.capture_exception(exception) 35 | -------------------------------------------------------------------------------- /lambda_collect_measurements/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.12.10 2 | sentry-sdk==0.17.4 3 | pynamodb==4.3.3 4 | -r ../db_access/requirements.txt 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r ./terraform/scripts/requirements.txt 2 | -r ./MicroPython/requirements.txt -------------------------------------------------------------------------------- /terraform/environments/devel.tfvars: -------------------------------------------------------------------------------- 1 | # Variables for the devel workspace 2 | # Feel free to change any of them 3 | 4 | mode = "devel" 5 | project = "mobile-iot" # change this to a unique name 6 | region = "eu-west-3" 7 | owner = "owner@info" #provide owner's info, e.g. your email address 8 | ESP_HARD_PASSWORD = "Your_ESP_Password" # change this 9 | ESP_HARD_LOGIN = "Your_ESP_Login" # change this -------------------------------------------------------------------------------- /terraform/environments/production.tfvars: -------------------------------------------------------------------------------- 1 | # Variables for the production workspace 2 | # Feel free to change any of them 3 | 4 | mode = "production" 5 | project = "mobile-iot" # change this to a unique name 6 | region = "eu-central-1" 7 | owner = "owner@info" #provide owner's info, e.g. your email address 8 | ESP_HARD_PASSWORD = "Your_ESP_Password" 9 | ESP_HARD_LOGIN = "Your_ESP_Login" -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | # Main file of IoT-Project infrastructure declaration 2 | # It takes configuration form vars.tf file, that's located in this directory 3 | # And from provided during execution .tfvars file (see README.md for more information) 4 | 5 | 6 | terraform { 7 | # the S3 backend store tfstate file on S3 bucket 8 | # It's the best solution if you plan to run terraform from many hosts machines. 9 | # See: https://www.terraform.io/docs/backends/index.html for more information 10 | backend "s3" { 11 | bucket = "test.iot.starter" 12 | key = "mobile-iot.wizzdev.tfstate" 13 | # This is different region then the one provided in .tfvar file. 14 | # Due to terraform specification variable cannot be passed here 15 | region = "eu-west-2" 16 | } 17 | } 18 | 19 | # Set up where all created services should be placed 20 | # The whole list of regions can be found here: https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/ 21 | provider "aws" { 22 | region = var.region 23 | } 24 | 25 | # Due to the lack of consistency of naming on AWS, some names require only '-' and some require only '_' 26 | locals { 27 | prefix = "${var.project}-${var.mode}" 28 | prefix_ = replace(local.prefix, "-", "_") 29 | # Create Map Object of tags, that will be added to created AWS resources 30 | tags = merge(var.default_tags, map("project", var.project, "owner", var.owner, "mode", var.mode)) 31 | } 32 | 33 | ### IoT Core module 34 | module "mobile-iot-core" { 35 | source = "./modules/iot_core" 36 | prefix = local.prefix 37 | } 38 | 39 | ### Lambdas module 40 | module "mobile-iot-lambdas" { 41 | source = "./modules/lambdas" 42 | 43 | # General settings 44 | mode = var.mode 45 | region = var.region 46 | tags = local.tags 47 | 48 | # Lambdas configuration 49 | lambda_policy_name = "${local.prefix}-standard-policy" 50 | lambda_collect_measurements = { 51 | function_name = "${local.prefix}-collect-measurements" 52 | function_handler = "main.main" 53 | function_timeout_in_seconds = 60 54 | } 55 | lambda_health_check = { 56 | function_name = "${local.prefix}-health-check" 57 | function_handler = "main.main" 58 | function_timeout_in_seconds = 60 59 | time_between_runs_in_minutes = 45 60 | } 61 | 62 | # Sentry integration 63 | collect_measurements_sentry_dsn = var.sentry_collect_measurements 64 | health_check_sentry_dsn = var.sentry_health_check 65 | } 66 | 67 | 68 | ### REST API module 69 | module "mobile-iot-web-server" { 70 | source = "./modules/rest_api" 71 | 72 | # General settings 73 | region = var.region 74 | prefix = local.prefix 75 | 76 | # REST API configuration 77 | rest_api_python_source_directory = "../../web_server/server" 78 | rest_api_s3_bucket_name = "${local.prefix}-web-server-lambda" 79 | rest_api_lambda_handler = "app.app" 80 | rest_api_lambda_runtime = "python3.7" 81 | rest_api_lambda_environment = { 82 | # ESP Passwords 83 | ESP_HARD_PASSWORD = var.ESP_HARD_PASSWORD 84 | ESP_HARD_LOGIN = var.ESP_HARD_LOGIN 85 | # DBLib 86 | DEVELOPMENT = var.mode == "development"? true : false 87 | DATABASE_PREFIX = "${var.mode}_db" 88 | IOT_AWS_REGION = var.region 89 | # Flask API 90 | SECRET_KEY = "624QnZdvBsuYzscCBadfd1Dg2tXxjpeMQXy3Sk7DE0RSfX4GZNKCG0CAvbUXIYeWIe" 91 | # Sentry integration 92 | MODE = var.mode 93 | SENTRY = var.sentry_rest_api 94 | # AWS oriented settings, used for creating new things 95 | API_REGION_AWS = var.region 96 | THING_TYPE_BASE_AWS = module.mobile-iot-core.base_thing_type 97 | THING_POLICY_BASE_AWS = module.mobile-iot-core.base_thing_policy 98 | } 99 | } 100 | 101 | 102 | ### Web visualization module 103 | module "mobile-iot-visualization" { 104 | source = "./modules/web_visualization" 105 | 106 | # General settings 107 | tags = local.tags 108 | mode = var.mode 109 | 110 | # Cloudfront configuration 111 | cloudfront_web_visualization = { 112 | cname = var.mode == "production" ? "mobile-iot.wizzdev.pl" : "mobile-iot-${var.mode}.wizzdev.pl" 113 | api_id = module.mobile-iot-web-server.rest_api_api_id 114 | api_arn = module.mobile-iot-web-server.rest_api_api_arn 115 | api_url = module.mobile-iot-web-server.rest_api_url 116 | } 117 | # S3 configuration 118 | s3_web_visualization = { 119 | bucket_name = "${replace(local.prefix, "_", "-")}-web-visualization" 120 | } 121 | } 122 | 123 | 124 | output "thing_type_base" { 125 | value = module.mobile-iot-core.base_thing_type 126 | } 127 | 128 | output "aws_iot_endpoint" { 129 | value = module.mobile-iot-core.url_iot_endpoint 130 | } 131 | 132 | output "visualization_url" { 133 | value = module.mobile-iot-visualization.url_visualization 134 | } 135 | 136 | output "esp_login" { 137 | value = var.ESP_HARD_LOGIN 138 | } 139 | 140 | output "esp_password" { 141 | value = var.ESP_HARD_PASSWORD 142 | } -------------------------------------------------------------------------------- /terraform/modules/iot_core/main.tf: -------------------------------------------------------------------------------- 1 | # Iot Core module 2 | # It takes care of establish connection between IoT devices and AWS Cloud 3 | # Implemented tasks: 4 | # - two thing types: base and test 5 | # - iot things policy 6 | 7 | resource "aws_iot_thing_type" "iot_thing_type_base" { 8 | name = "${var.prefix}_tf" 9 | } 10 | 11 | resource "aws_iot_thing_type" "iot_thing_type_test" { 12 | name = "${var.prefix}_test" 13 | } 14 | 15 | resource "aws_iot_policy" "iot_core_policy" { 16 | name = "${var.prefix}_iot_core_connect_and_publish_to_topic" 17 | policy = data.aws_iam_policy_document.iot_core_policy_document.json 18 | } -------------------------------------------------------------------------------- /terraform/modules/iot_core/outputs.tf: -------------------------------------------------------------------------------- 1 | # Save Account IoT Endpoint to tfstate file 2 | 3 | data aws_iot_endpoint "wizzdev_iot_endpoint" {} 4 | 5 | output "url_iot_endpoint" { 6 | value = data.aws_iot_endpoint.wizzdev_iot_endpoint 7 | } 8 | 9 | output "base_thing_type" { 10 | value = aws_iot_thing_type.iot_thing_type_base.name 11 | } 12 | 13 | output "base_thing_policy" { 14 | value = aws_iot_policy.iot_core_policy.name 15 | } -------------------------------------------------------------------------------- /terraform/modules/iot_core/policies.tf: -------------------------------------------------------------------------------- 1 | # Thing policy allow to publish, subscribe, connect and receive 2 | 3 | data "aws_iam_policy_document" "iot_core_policy_document" { 4 | statement { 5 | actions = [ 6 | "iot:Publish", 7 | "iot:Subscribe", 8 | "iot:Connect", 9 | "iot:Receive" 10 | ] 11 | resources = [ 12 | "*" 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /terraform/modules/iot_core/vars.tf: -------------------------------------------------------------------------------- 1 | # Prefix will be attached to names and descriptions 2 | variable prefix { 3 | type = string 4 | } -------------------------------------------------------------------------------- /terraform/modules/lambdas/collect_measurements.tf: -------------------------------------------------------------------------------- 1 | # Lambdas - Collect Measurement 2 | # It takes care of architecture for collect measurements lambda 3 | # Implemented tasks: 4 | # - create lambda and lambda policy 5 | # - deploy zip package to lambda 6 | # - trigger lambda with new reads from Iot device 7 | 8 | resource "aws_lambda_function" "collect_measurements_lambda" { 9 | function_name = local.lambda_collect_measurements_name 10 | handler = var.lambda_collect_measurements.function_handler 11 | timeout = var.lambda_collect_measurements.function_timeout_in_seconds 12 | 13 | runtime = "python3.6" 14 | memory_size = 128 15 | 16 | role = aws_iam_role.collect_measurements_iam_role.arn 17 | filename = data.archive_file.collect_measurements_lambda_zip.output_path 18 | source_code_hash = data.archive_file.collect_measurements_lambda_zip.output_base64sha256 19 | 20 | tags = var.tags 21 | environment { 22 | variables = { 23 | DATABASE_PREFIX = "${var.mode}_db" 24 | SENTRY = var.collect_measurements_sentry_dsn 25 | IOT_AWS_REGION = var.region 26 | MODE = var.mode 27 | } 28 | } 29 | } 30 | 31 | resource "aws_iot_topic_rule" "collect_measurements_iot_core_topic" { 32 | name = local.lambda_collect_measurements_name_ 33 | 34 | sql = "SELECT * FROM 'topic/data'" 35 | sql_version = "2015-10-08" 36 | enabled = true 37 | 38 | lambda { 39 | function_arn = aws_lambda_function.collect_measurements_lambda.arn 40 | } 41 | } 42 | 43 | resource "aws_iam_role" "collect_measurements_iam_role" { 44 | name = local.lambda_collect_measurements_name 45 | 46 | description = "IAM role for 'collecting measurements'" 47 | assume_role_policy = data.aws_iam_policy_document.lambda_standard_role_policy_document.json 48 | 49 | tags = var.tags 50 | } 51 | 52 | resource "aws_iam_role_policy_attachment" "collect_measurements_join_policy" { 53 | policy_arn = aws_iam_policy.lambda_standard_policy.arn 54 | 55 | role = aws_iam_role.collect_measurements_iam_role.name 56 | } 57 | 58 | resource "aws_iam_role_policy_attachment" "collect_measurements_join_policy_iot_core_access" { 59 | policy_arn = aws_iam_policy.lambda_iot_core_access_policy.arn 60 | 61 | role = aws_iam_role.collect_measurements_iam_role.name 62 | } 63 | 64 | resource "null_resource" "collect_measurements_lambda_trigger" { 65 | triggers = { 66 | timestamp = timestamp() 67 | } 68 | provisioner "local-exec" { 69 | command = "echo 1" 70 | } 71 | } 72 | 73 | data "archive_file" "collect_measurements_lambda_zip" { 74 | depends_on = [ 75 | null_resource.collect_measurements_lambda_trigger 76 | ] 77 | type = "zip" 78 | source_dir = "./.tmp/lambda_collect_measurements" 79 | output_path = "./.tmp/${var.lambda_collect_measurements.function_name}.zip" 80 | } 81 | 82 | // Deprecation: build with build_all.py script 83 | //resource "null_resource" "collect_measurements_prepare_lambda_package" { 84 | // provisioner "local-exec" { 85 | // working_dir = "./scripts" 86 | // command = "python build_lambda.py ../../lambda_collect_measurements ../.tmp/lambda_collect_measurements -v INFO --include-db-access" 87 | // } 88 | // count = var.skip_build 89 | // triggers = { 90 | // always = timestamp() 91 | // } 92 | //} -------------------------------------------------------------------------------- /terraform/modules/lambdas/health_check.tf: -------------------------------------------------------------------------------- 1 | # Lambdas - Collect Measurement 2 | # It takes care of architecture for collect measurements lambda 3 | # Implemented tasks: 4 | # - create lambda and lambda policy 5 | # - deploy zip package to lambda 6 | # - add cloudwatch event, which trigger lambda every X minutes, where X is defined in vars.tf file 7 | 8 | resource "aws_lambda_function" "health_check_lambda" { 9 | function_name = local.lambda_health_check_name 10 | handler = var.lambda_health_check.function_handler 11 | timeout = var.lambda_health_check.function_timeout_in_seconds 12 | 13 | runtime = "python3.6" 14 | memory_size = 128 15 | 16 | role = aws_iam_role.health_check_iam_role.arn 17 | filename = data.archive_file.health_check_lambda_zip.output_path 18 | source_code_hash = data.archive_file.health_check_lambda_zip.output_base64sha256 19 | 20 | tags = var.tags 21 | environment { 22 | variables = { 23 | DATABASE_PREFIX = "${var.mode}_db" 24 | SENTRY = var.health_check_sentry_dsn 25 | IOT_AWS_REGION = var.region 26 | MODE = var.mode 27 | } 28 | } 29 | } 30 | 31 | resource "aws_iam_role" "health_check_iam_role" { 32 | name = local.lambda_health_check_name 33 | 34 | description = "IAM role for 'collecting measurements'" 35 | assume_role_policy = data.aws_iam_policy_document.lambda_standard_role_policy_document.json 36 | 37 | tags = var.tags 38 | } 39 | 40 | resource "aws_iam_role_policy_attachment" "health_check_join_policy" { 41 | policy_arn = aws_iam_policy.lambda_standard_policy.arn 42 | 43 | role = aws_iam_role.health_check_iam_role.name 44 | } 45 | 46 | resource "aws_cloudwatch_event_rule" "health_check_cloudwatch_event_rule" { 47 | name = local.lambda_health_check_name 48 | description = "Cron job for ${local.lambda_health_check_name}" 49 | schedule_expression = "rate(${var.lambda_health_check.time_between_runs_in_minutes} minutes)" 50 | 51 | tags = var.tags 52 | } 53 | 54 | resource "aws_cloudwatch_event_target" "health_check_assign_rule" { 55 | target_id = "lambda" 56 | rule = aws_cloudwatch_event_rule.health_check_cloudwatch_event_rule.name 57 | arn = aws_lambda_function.health_check_lambda.arn 58 | } 59 | 60 | resource "aws_lambda_permission" "allow_cloudwatch_to_call_check_foo" { 61 | statement_id = "AllowExecutionFromCloudWatch" 62 | action = "lambda:InvokeFunction" 63 | principal = "events.amazonaws.com" 64 | function_name = aws_lambda_function.health_check_lambda.function_name 65 | source_arn = aws_cloudwatch_event_rule.health_check_cloudwatch_event_rule.arn 66 | } 67 | 68 | data "archive_file" "health_check_lambda_zip" { 69 | depends_on = [ 70 | null_resource.health_check_lambda_trigger 71 | ] 72 | type = "zip" 73 | source_dir = "./.tmp/lambda_health_check" 74 | output_path = "./.tmp/${var.lambda_health_check.function_name}.zip" 75 | } 76 | 77 | resource "null_resource" "health_check_lambda_trigger" { 78 | triggers = { 79 | timestamp = timestamp() 80 | } 81 | provisioner "local-exec" { 82 | command = "echo 1" 83 | } 84 | } 85 | // Deprecation: build with build_all.py script 86 | //resource "null_resource" "health_check_prepare_lambda_package" { 87 | // provisioner "local-exec" { 88 | // working_dir = "./scripts" 89 | // command = "python build_lambda.py ../../lamba_health_check ../.tmp/lambda_health_check -v INFO --include-db-access" 90 | // } 91 | // count = var.skip_build 92 | // triggers = { 93 | // always = timestamp() 94 | // } 95 | //} 96 | -------------------------------------------------------------------------------- /terraform/modules/lambdas/locals.tf: -------------------------------------------------------------------------------- 1 | # Create lambdas specific names 2 | # Due to the lack of consistency of naming on AWS, some names require only '-' and some require only '_' 3 | 4 | locals { 5 | lambda_collect_measurements_name = replace(var.lambda_collect_measurements.function_name, "_", "-") 6 | lambda_collect_measurements_name_ = replace(var.lambda_collect_measurements.function_name, "-", "_") 7 | lambda_health_check_name = replace(var.lambda_health_check.function_name, "_", "-") 8 | lambda_health_check_name_ = replace(var.lambda_health_check.function_name, "-", "_") 9 | } 10 | -------------------------------------------------------------------------------- /terraform/modules/lambdas/policies.tf: -------------------------------------------------------------------------------- 1 | # Define policies for specified resources 2 | # Create 'lambda_standard_policy' and 'lambda_iot_core_access_policy' 3 | 4 | data "aws_iam_policy_document" "lambda_iot_core_access_policy_document" { 5 | statement { 6 | actions = [ 7 | "iot:GetThingShadow", 8 | ] 9 | resources = [ 10 | "*" 11 | ] 12 | } 13 | } 14 | data "aws_iam_policy_document" "lambda_standard_policy_document" { 15 | 16 | /* Dynamodb Access */ 17 | statement { 18 | actions = [ 19 | "dynamodb:PutItem", 20 | "dynamodb:DescribeTable", 21 | "dynamodb:CreateTable", 22 | "dynamodb:GetItem", 23 | "dynamodb:Scan", 24 | "dynamodb:Query", 25 | "dynamodb:UpdateItem", 26 | "dynamodb:BatchWriteItem", 27 | "dynamodb:BatchGetItem", 28 | ] 29 | resources = [ 30 | "*" 31 | ] 32 | } 33 | 34 | /* Sending Mails, SES */ 35 | # TODO: remove it in starter 36 | statement { 37 | actions = [ 38 | "ses:SendEmail", 39 | "ses:SendRawEmail" 40 | ] 41 | resources = [ 42 | "*" 43 | ] 44 | } 45 | 46 | /* Putting and reading logs */ 47 | statement { 48 | actions = [ 49 | "logs:CreateLogStream", 50 | "logs:PutLogEvents" 51 | ] 52 | resources = [ 53 | "arn:aws:logs:*:*:*" 54 | ] 55 | } 56 | } 57 | data "aws_iam_policy_document" "lambda_standard_role_policy_document" { 58 | statement { 59 | actions = [ 60 | "sts:AssumeRole" 61 | ] 62 | principals { 63 | type = "Service" 64 | identifiers = [ 65 | "lambda.amazonaws.com" 66 | ] 67 | } 68 | } 69 | } 70 | 71 | resource "aws_iam_policy" "lambda_standard_policy" { 72 | name = var.lambda_policy_name 73 | policy = data.aws_iam_policy_document.lambda_standard_policy_document.json 74 | } 75 | 76 | resource "aws_iam_policy" "lambda_iot_core_access_policy" { 77 | name = "${var.lambda_policy_name}_for_iot_core" 78 | policy = data.aws_iam_policy_document.lambda_iot_core_access_policy_document.json 79 | } 80 | -------------------------------------------------------------------------------- /terraform/modules/lambdas/vars.tf: -------------------------------------------------------------------------------- 1 | # Parameters for Lambdas module 2 | 3 | # Will be taken from environments/file.tfvars 4 | variable region { 5 | type = string 6 | } 7 | 8 | # Will be taken from environments/file.tfvars 9 | variable "mode" { 10 | type = string 11 | } 12 | 13 | # Will be constructed basing on environments/file.tfvars 14 | variable "tags" { 15 | type = map 16 | default = {} 17 | } 18 | 19 | 20 | # Default lambda oriented settings 21 | variable lambda_policy_name { 22 | type = string 23 | } 24 | 25 | # Configuration of lambda_collect_measurements 26 | variable "lambda_collect_measurements" { 27 | type = object({ 28 | function_name = string, 29 | function_handler = string 30 | function_timeout_in_seconds = number 31 | }) 32 | } 33 | 34 | # Configuration of lambda_health_check 35 | variable "lambda_health_check" { 36 | type = object({ 37 | function_name = string, 38 | function_handler = string 39 | function_timeout_in_seconds = number 40 | time_between_runs_in_minutes = number 41 | }) 42 | } 43 | 44 | 45 | # Configuration of sentry integration 46 | # It's optional feature, that doesn't needs to be provided 47 | variable "collect_measurements_sentry_dsn" { 48 | type = string 49 | default = "" 50 | } 51 | 52 | # Configuration of sentry integration 53 | # It's optional feature, that doesn't needs to be provided 54 | variable "health_check_sentry_dsn" { 55 | type = string 56 | default = "" 57 | } -------------------------------------------------------------------------------- /terraform/modules/rest_api/locals.tf: -------------------------------------------------------------------------------- 1 | # Create REST API specific names 2 | # Due to the lack of consistency of naming on AWS, some names require only '-' and some require only '_' 3 | 4 | locals { 5 | name = replace("${var.prefix}-${basename(var.rest_api_python_source_directory)}", "_", "-") 6 | s3_bucket_name = replace(var.rest_api_s3_bucket_name, "_", "-") 7 | } 8 | -------------------------------------------------------------------------------- /terraform/modules/rest_api/outputs.tf: -------------------------------------------------------------------------------- 1 | # Save REST API parameters 2 | # They will be saved in tfstate file and always are used by Web Visualization module 3 | 4 | output "rest_api_api_id" { 5 | value = aws_api_gateway_rest_api.rest_api_gateway_rest_api.id 6 | } 7 | 8 | output "rest_api_api_arn" { 9 | value = aws_api_gateway_rest_api.rest_api_gateway_rest_api.arn 10 | } 11 | 12 | output "rest_api_url" { 13 | value = "${aws_api_gateway_rest_api.rest_api_gateway_rest_api.id}.execute-api.${var.region}.amazonaws.com" 14 | } 15 | 16 | output "rest_api_role_id" { 17 | value = aws_iam_role.rest_api_lambda_role.id 18 | } -------------------------------------------------------------------------------- /terraform/modules/rest_api/policies.tf: -------------------------------------------------------------------------------- 1 | # Define policies for specified resources 2 | # Create 'lambda_standard_policy_document' for REST API 3 | 4 | data "aws_iam_policy_document" "lambda_standard_policy_document" { 5 | 6 | /* Dynamodb Access */ 7 | statement { 8 | actions = [ 9 | "dynamodb:PutItem", 10 | "dynamodb:DescribeTable", 11 | "dynamodb:CreateTable", 12 | "dynamodb:GetItem", 13 | "dynamodb:Scan", 14 | "dynamodb:Query", 15 | "dynamodb:UpdateItem", 16 | "dynamodb:BatchWriteItem", 17 | "dynamodb:BatchGetItem", 18 | ] 19 | resources = [ 20 | "*" 21 | ] 22 | } 23 | 24 | /* Iot things */ 25 | statement { 26 | actions = [ 27 | "iot:*", 28 | ] 29 | resources = [ 30 | "*" 31 | ] 32 | } 33 | 34 | /* Putting and reading logs */ 35 | statement { 36 | actions = [ 37 | "logs:CreateLogGroup", 38 | "logs:CreateLogStream", 39 | "logs:PutLogEvents", 40 | "logs:DescribeLogStreams" 41 | ] 42 | resources = [ 43 | "arn:aws:logs:*:*:*" 44 | ] 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /terraform/modules/rest_api/vars.tf: -------------------------------------------------------------------------------- 1 | # Parameters for REST API module 2 | 3 | # S3 bucket name for REST API lambda code 4 | variable "rest_api_s3_bucket_name" { 5 | type = string 6 | } 7 | 8 | # Source dir relative path 9 | variable "rest_api_python_source_directory" { 10 | type = string 11 | } 12 | 13 | # Handler to flask application 14 | variable "rest_api_lambda_handler" { 15 | type = string 16 | } 17 | 18 | # Which version of python lambda should use 19 | variable "rest_api_lambda_runtime" { 20 | type = string 21 | } 22 | 23 | # Prefix will be attached to names and descriptions 24 | variable "prefix" { 25 | type = string 26 | default = "" 27 | } 28 | 29 | variable "region" { 30 | type = string 31 | } 32 | 33 | # Set up system environment variables for lambda 34 | variable "rest_api_lambda_environment" { 35 | type = map(string) 36 | default = { 37 | VERSION = 1 38 | } 39 | } 40 | 41 | # Maximum time of lambda run 42 | variable "rest_api_lambda_timeout" { 43 | type = string 44 | default = "35" 45 | } 46 | 47 | -------------------------------------------------------------------------------- /terraform/modules/web_visualization/certificate.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/terraform/modules/web_visualization/certificate.tf -------------------------------------------------------------------------------- /terraform/modules/web_visualization/outputs.tf: -------------------------------------------------------------------------------- 1 | output "url_visualization" { 2 | value = aws_cloudfront_distribution.visualization_cloudfront.domain_name 3 | } -------------------------------------------------------------------------------- /terraform/modules/web_visualization/s3_bucket.tf: -------------------------------------------------------------------------------- 1 | # Web Visualization - S3 bucket 2 | # It creates S3 bucket with provided name and attach to it policy that allow public read access 3 | 4 | resource "aws_s3_bucket" "web_visualization_s3_bucket" { 5 | bucket = var.s3_web_visualization.bucket_name 6 | 7 | acl = "public-read" 8 | force_destroy = true 9 | 10 | website { 11 | index_document = "index.html" 12 | error_document = "index.html" 13 | } 14 | 15 | tags = var.tags 16 | } 17 | 18 | # Policy that allows public read only access to S3 bucket 19 | resource "aws_s3_bucket_policy" "web_visualization_s3_bucket_policy" { 20 | bucket = aws_s3_bucket.web_visualization_s3_bucket.id 21 | policy = <=1.20 6 | awscli==1.18.17 7 | pyserial==3.5 8 | cryptography==3.4.8 9 | click==8.0.1 10 | future==0.18.2 11 | pyelftools==0.27 12 | setuptools==47.1.0 13 | 14 | -------------------------------------------------------------------------------- /terraform/scripts/upload_frontend.py: -------------------------------------------------------------------------------- 1 | """ 2 | The utility script that upload all files from provided directory to desire S3 bucket 3 | To run this script you need to obtain S3 related permissions. 4 | Script detects and adds mime types for html, css and javascript files only. 5 | It's configurable with following parameters: 6 | S3_BUCKET (required) name of S3 bucket 7 | BUILD_DIR (required) path to input directory 8 | -v, --verbose (optional) set logging level 9 | """ 10 | 11 | import os, pathlib 12 | import argparse 13 | import logging 14 | 15 | import boto3 16 | 17 | 18 | def parse_args(): 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument("S3_BUCKET") 21 | parser.add_argument("BUILD_DIR") 22 | parser.add_argument("-v", "--verbose", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR"], default="INFO") 23 | return parser.parse_args() 24 | 25 | 26 | def get_content_type(path: str): 27 | if not isinstance(path, str): 28 | path = str(path) 29 | if path.endswith('html'): 30 | return 'text/html' 31 | if path.endswith('css'): 32 | return 'text/css' 33 | if path.endswith('js'): 34 | return 'text/javascript' 35 | 36 | 37 | def upload_package(build_dir_path, s3_bucket): 38 | s3 = boto3.client('s3') 39 | bucket_entries = s3.list_objects_v2(Bucket=s3_bucket).get('Contents', []) 40 | for entry in bucket_entries: 41 | s3.delete_object(Bucket=s3_bucket, Key=entry['Key']) 42 | for root, dirs, files in os.walk(build_dir_path): 43 | for file in files: 44 | name = os.path.join(root, file).replace(str(build_dir_path), "") 45 | if name.startswith('\\'): 46 | name = name[1:] 47 | if name.startswith('/'): 48 | name = name[1:] 49 | name = name.replace('\\', '/') 50 | content_type = get_content_type(name) 51 | extra_args = { 52 | 'ACL': 'public-read', 53 | } 54 | if content_type: 55 | extra_args['ContentType'] = content_type 56 | logging.info(f"Uploading {name}") 57 | s3.upload_file(os.path.join(root, file), s3_bucket, name, ExtraArgs=extra_args) 58 | 59 | 60 | if __name__ == '__main__': 61 | args = parse_args() 62 | logging.basicConfig(level=args.verbose) 63 | # Paths 64 | build_dir = pathlib.Path(args.BUILD_DIR).resolve().absolute() 65 | s3_bucket = args.S3_BUCKET 66 | logging.info(f"Resolve arguments: \n BUILD_DIR: {build_dir} \n S3 BUCKET: {s3_bucket}") 67 | # Upload 68 | upload_package(build_dir, s3_bucket) 69 | -------------------------------------------------------------------------------- /terraform/sentry.tf: -------------------------------------------------------------------------------- 1 | variable "sentry_collect_measurements" { 2 | type = string 3 | default = "https://c9a51559c1c34de3be3bc55ac3a0a47c@o258895.ingest.sentry.io/5428103" 4 | } 5 | 6 | variable "sentry_health_check" { 7 | type = string 8 | default = "https://f9e30e8939dc480094205993a6053420@o258895.ingest.sentry.io/5428162" 9 | } 10 | 11 | variable "sentry_rest_api" { 12 | type = string 13 | default = "https://319bea5f15b2497781e85073a17c64fa@o258895.ingest.sentry.io/5428165" 14 | } -------------------------------------------------------------------------------- /terraform/vars.tf: -------------------------------------------------------------------------------- 1 | variable "mode" { 2 | type = string 3 | description = "production or devel" 4 | } 5 | 6 | variable "region" { 7 | type = string 8 | description = "AWS region, ex. eu-west-2" 9 | } 10 | 11 | variable "default_tags" { 12 | type = map 13 | default = { 14 | created_by = "terraform" 15 | } 16 | } 17 | 18 | variable "project" { 19 | type = string 20 | description = "Tag: Name of project" 21 | default = "Not Provided" 22 | } 23 | 24 | variable "owner" { 25 | type = string 26 | description = "Tag: Mail of administrator" 27 | default = "Not Provided" 28 | } 29 | 30 | variable "ESP_HARD_PASSWORD" { 31 | description = "Constant password to connect from ESP" 32 | type = string 33 | } 34 | 35 | variable "ESP_HARD_LOGIN" { 36 | description = "Constant password to connect from ESP" 37 | type = string 38 | } -------------------------------------------------------------------------------- /utils/connect_as_thing_with_iot_core.py: -------------------------------------------------------------------------------- 1 | from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTShadowClient 2 | import os 3 | import sys 4 | 5 | CLIENT_ID = "ESP32840d8ee67d9c" 6 | HOST = "a1kdus6mbpzad4.iot.eu-west-3.amazonaws.com" 7 | PORT = 8883 8 | ROOT_CA_PATH = "/home/developer/Projects/wizzdev-iot-starter/MicroPython/src/certificates/AWS.ca_certificate" 9 | PRIVATE_KEY_PATH = "/home/developer/Projects/wizzdev-iot-starter/MicroPython/src/certificates/AWS.private_key" 10 | CERTIFICATE_PATH = "/home/developer/Projects/wizzdev-iot-starter/MicroPython/src/certificates/AWS.certificate" 11 | 12 | 13 | # Init AWSIoTMQTTShadowClient 14 | myAWSIoTMQTTShadowClient = AWSIoTMQTTShadowClient(CLIENT_ID) 15 | myAWSIoTMQTTShadowClient.configureEndpoint(HOST, PORT) 16 | myAWSIoTMQTTShadowClient.configureCredentials(ROOT_CA_PATH, 17 | PRIVATE_KEY_PATH, 18 | CERTIFICATE_PATH) 19 | 20 | # AWSIoTMQTTShadowClient connection configuration 21 | myAWSIoTMQTTShadowClient.configureAutoReconnectBackoffTime(1, 32, 20) 22 | myAWSIoTMQTTShadowClient.configureConnectDisconnectTimeout(10) # 10 sec 23 | myAWSIoTMQTTShadowClient.configureMQTTOperationTimeout(25) # 5 sec 24 | 25 | # Connect to AWS IoT 26 | myAWSIoTMQTTShadowClient.connect() 27 | print("DONE") -------------------------------------------------------------------------------- /web_server/README.md: -------------------------------------------------------------------------------- 1 | # Web Server 2 | `web_server` is a flask based application, hosting static html sites and dynamic API to request measurements data. 3 | It uses library `db_access` to access the data from dynamodb. 4 | Application is supposed to be hosted on AWS serveless, with access via www, without any logging. 5 | Dedicated site allows to see list of devices, graphs and download the data as csv. 6 | 7 | ### Development 8 | For local development, `web_server` can be hosted locally - just run `./src/main.py` file. 9 | Make sure that both `db_access` and `web_server` directories are in path and `requirementst.txt` are installed. 10 | 11 | ### Configuration 12 | Server can be configured with system environments: 13 | * **PAGE_SIZE** (TBD) 14 | * **CORS** Turn on/off CORS. Cors is enabled by default. 15 | * **NO_ROBOTS** Disable search engine spiders. Enabled by default. 16 | 17 | And some more from ***db_access*** library: 18 | * **IOT_AWS_REGION** region, where are DynamoDB tables 19 | * **DATABASE_PREFIX** prefix added to all DynamoDB tables 20 | * **DATABASE_HOST** you can setup it to localhost:port to use Dynalite instead of AWS DynamoDB 21 | * **DEBUG** add "_dev" suffix for all DynamoDB tables 22 | 23 | # Hosting with serverless 24 | ``` 25 | sudo apt install npm 26 | ``` 27 | Pack `web_server` and `db_access` directories (both need to be in path) and install requirements. 28 | TODO - how to host it 29 | 30 | 31 | -------------------------------------------------------------------------------- /web_server/client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /web_server/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: ["plugin:vue/essential", "eslint:recommended"], 7 | parserOptions: { 8 | parser: "babel-eslint", 9 | }, 10 | rules: { 11 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 12 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 13 | "no-unused-vars": process.env.NODE_ENV === "production" ? "warn" : "off", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /web_server/client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /web_server/client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"] 3 | }; 4 | -------------------------------------------------------------------------------- /web_server/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wizzdev-mobile-iot", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve --host localhost", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "add": "vue-cli-service add vuetify" 10 | }, 11 | "dependencies": { 12 | "@hapi/joi": "^17.1.1", 13 | "axios": "^0.19.2", 14 | "bootstrap": "^4.5.0", 15 | "bootstrap-vue": "^2.15.0", 16 | "chart.js": "^2.9.3", 17 | "chartjs-plugin-zoom": "^0.7.7", 18 | "core-js": "^3.6.4", 19 | "downsample": "^1.3.0", 20 | "font-awesome": "^4.7.0", 21 | "i18n": "^0.9.0", 22 | "moment": "^2.26.0", 23 | "moment-timezone": "^0.5.31", 24 | "vue": "^2.6.11", 25 | "vue-chartjs": "^3.5.0", 26 | "vue-i18n": "^8.17.4", 27 | "vue-material": "^1.0.0-beta-7", 28 | "vue-router": "^3.1.6", 29 | "vue-toasted": "^1.1.28", 30 | "vuejs-datetimepicker": "^1.1.13", 31 | "vuex": "^3.1.3", 32 | "webpack-cli": "^3.3.11" 33 | }, 34 | "devDependencies": { 35 | "@nuxtjs/vuetify": "^1.11.2", 36 | "@vue/cli-plugin-babel": "~4.3.0", 37 | "@vue/cli-plugin-eslint": "~4.3.0", 38 | "@vue/cli-plugin-router": "~4.3.0", 39 | "@vue/cli-plugin-vuex": "~4.3.0", 40 | "@vue/cli-service": "~4.3.0", 41 | "@vue/eslint-config-prettier": "^6.0.0", 42 | "babel-eslint": "^10.1.0", 43 | "eslint": "^6.7.2", 44 | "eslint-plugin-prettier": "^3.1.1", 45 | "eslint-plugin-vue": "^6.2.2", 46 | "node-sass": "^4.14.1", 47 | "prettier": "^1.19.1", 48 | "sass": "^1.19.0", 49 | "sass-loader": "^7.0.1", 50 | "vue-template-compiler": "^2.6.11" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /web_server/client/src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ $t("title") }} 6 | 7 | 8 | 9 | 10 | dashboard 11 | Dashboard 12 | 13 | 14 | 15 | 16 | 17 | Devices 18 | 19 | 20 | 21 | 22 | {{time}} 23 | 24 | refresh 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 94 | 95 | -------------------------------------------------------------------------------- /web_server/client/src/common/api.helper.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Vue from "vue"; 3 | 4 | import Configuration from "../config"; 5 | 6 | 7 | export default class ApiHelper { 8 | constructor (base_url) { 9 | this.base_url = base_url 10 | } 11 | 12 | /* Save gathered items from API to items array 13 | * Available options: 14 | * queryParams: {name=value} - get arguments 15 | * suffix: string - added to url after base url 16 | * skipError: boolean - do not toast error message 17 | */ 18 | async getList (context, action, options) { 19 | options = (options || {}); 20 | let url = Configuration.API_BASE_URL + this.base_url + (options.suffix || ""); 21 | axios.get(url, {params: (options.queryParams)}) 22 | .then(response => { 23 | context.commit(action, ApiHelper.normalizeData(response.data['data'])); 24 | return response.data['data']; 25 | }) 26 | .catch(error => { 27 | if (options.skipError !== true) this.toastApiError(undefined, error); 28 | return null; 29 | }); 30 | } 31 | 32 | /* Save gathered item from API to items object under selected key 33 | * Available options: 34 | * queryParams: {name=value} - get arguments 35 | * suffix: string - added to url after base url 36 | * skipError: boolean - do not toast error message 37 | */ 38 | async getOne (context, action, itemKey, options) { 39 | options = (options || {}); 40 | let url = Configuration.API_BASE_URL + this.base_url + (options.suffix || ""); 41 | axios.get(url, {params: (options.queryParams)}) 42 | .then(response => { 43 | context.commit(action, {itemKey: itemKey, data: response.data['data']}); 44 | return response.data['data']; 45 | }) 46 | .catch(error => { 47 | if (options.skipError !== true) this.toastApiError(undefined, error); 48 | return null 49 | }); 50 | } 51 | 52 | /* Create notification for user about error */ 53 | toastApiError (message, error) { 54 | Vue.toasted.error( 55 | message ? message : this.createStandardApiErrorMessage() + '' + error, { 56 | duration: Configuration.TOAST_DISPLAY_TIME_IN_MS, // How long it would be displayed in milliseconds 57 | action: { 58 | text: 'Hide', 59 | onClick: (e, toastObject) => { 60 | toastObject.goAway(0); 61 | } 62 | }, 63 | }); 64 | if (Configuration.DEBUG) console.log(error); 65 | } 66 | 67 | /* Create standard generic api error message for user 68 | * Argument "customUrl" is optional, if empty, the base_url would be taken */ 69 | createStandardApiErrorMessage (customUrl) { 70 | let url = customUrl || this.base_url; 71 | return url + ": Cannot communicate with API. Please try again later." 72 | } 73 | 74 | /* Removes deleted files and 'is_delete' property from provided array of objects */ 75 | static normalizeData (data) { 76 | return data.filter((i) => !i.is_removed).map((i) => { 77 | delete i.is_removed; 78 | return i 79 | }); 80 | } 81 | } -------------------------------------------------------------------------------- /web_server/client/src/common/colors.helper.js: -------------------------------------------------------------------------------- 1 | export default { 2 | getColor (id) { 3 | const colorId = "color-" + id; 4 | let color = localStorage.getItem(colorId); 5 | if (!color) { 6 | color = "#"+((1<<24)*Math.random()|0).toString(16); 7 | localStorage.setItem(colorId, color); 8 | } 9 | return color; 10 | } 11 | } -------------------------------------------------------------------------------- /web_server/client/src/common/line.chart.js: -------------------------------------------------------------------------------- 1 | import {Line} from 'vue-chartjs' 2 | import "chartjs-plugin-zoom" 3 | 4 | export default { 5 | extends: Line, 6 | props: ['chartData', "unit"], 7 | computed: { 8 | dates: function () { 9 | return this.chartData.datasets.map(dataset => dataset.data).flat().map(entry => entry.x); 10 | }, 11 | values: function () { 12 | return this.chartData.datasets.map(dataset => dataset.data).flat().map(entry => entry.y); 13 | }, 14 | xMin: function () { 15 | return Math.min(...this.dates) 16 | }, 17 | xMax: function () { 18 | return Math.max(...this.dates) 19 | }, 20 | yMin: function () { 21 | return Math.min(...this.values) 22 | }, 23 | yMax: function () { 24 | return Math.max(...this.values) 25 | 26 | }, 27 | options: function () { 28 | const measurementTypeUnit = this.unit; 29 | return { 30 | normalized: true, 31 | responsive: true, 32 | maintainAspectRatio: false, 33 | animation: {duration: 0}, 34 | legend: { 35 | position: 'bottom', 36 | }, 37 | scales: { 38 | xAxes: [{ 39 | type: 'time', 40 | display: true, 41 | scaleLabel: { 42 | display: true, 43 | labelString: 'Date' 44 | }, 45 | time: { 46 | tooltipFormat: 'YYYY-MM-DD HH:mm', 47 | displayFormats: { 48 | millisecond: 'HH:mm:ss.SSS', 49 | second: 'HH:mm:ss', 50 | minute: 'HH:mm', 51 | hour: 'MMM D HH:mm', 52 | day: "MMM D HH:mm" 53 | } 54 | }, 55 | ticks: { 56 | maxTicksLimit: 30, 57 | source: 'auto', 58 | major: { 59 | fontStyle: 'bold', 60 | fontColor: '#FF0000' 61 | } 62 | } 63 | }], 64 | yAxes: [{ 65 | display: true, 66 | ticks: { 67 | callback: function (value, index, values) { 68 | if (measurementTypeUnit) return value + measurementTypeUnit; 69 | return value; 70 | } 71 | }, 72 | scaleLabel: { 73 | display: true, 74 | } 75 | }] 76 | }, 77 | plugins: { 78 | zoom: { 79 | pan: { 80 | enabled: true, 81 | mode: 'xy', 82 | rangeMin: {x: this.xMin - 1000, y: this.yMin - 10}, 83 | rangeMax: {x: this.xMax + 1000, y: this.yMax + 10} 84 | }, 85 | zoom: { 86 | enabled: true, 87 | mode: "xy", 88 | threshold: 0.1, 89 | speed: 0.1, 90 | rangeMin: {x: this.xMin - 1000, y: this.yMin - 10}, 91 | rangeMax: {x: this.xMax, y: this.yMax} 92 | } 93 | } 94 | } 95 | } 96 | } 97 | }, 98 | watch: { 99 | chartData (newData, oldData) { 100 | this.renderChart(newData, this.options) 101 | } 102 | }, 103 | mounted () { 104 | this.renderChart(this.chartData, this.options) 105 | }, 106 | } 107 | -------------------------------------------------------------------------------- /web_server/client/src/common/utils.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import config from "../config"; 3 | 4 | export default { 5 | getTimezoneOffset() { 6 | // Return timezone offset in milliseconds 7 | return (new Date()).getTimezoneOffset() * 60 * 1000; 8 | }, 9 | getCurrentTimestamp () { 10 | // Return current unix utc timestamp 11 | return moment().utc().valueOf() 12 | }, 13 | getDefaultMinTimestamp () { 14 | return this.getCurrentTimestamp() - config.DEFAULT_MIN_TIMESTAMP_RANGE 15 | }, 16 | prettyPrint (string) { 17 | // Change underscore string into nice looking sentence with all words capitalized 18 | if (!string) return ""; 19 | return string 20 | .split('_') 21 | .map(word => word.toLowerCase()) 22 | .map(word => word.charAt(0).toUpperCase() + word.slice(1)) 23 | .join(" "); 24 | }, 25 | } -------------------------------------------------------------------------------- /web_server/client/src/components/devices.table.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 19 | 20 | 21 | 22 | Loading ... 23 | 24 | 25 | 26 | 27 | label 28 | 29 | {{ (data.value) }} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /web_server/client/src/components/measurement_types.charts.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading ... 7 | 8 | 9 | 10 | 14 | 19 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 61 | 62 | -------------------------------------------------------------------------------- /web_server/client/src/components/measurements.table.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 19 | 20 | 21 | 22 | Loading ... 23 | 24 | 25 | 26 | 27 | {{ getDateFromTimestamp(data.value) }} 28 | 29 | 30 | 31 | label 32 | 33 | {{ (data.value) }} 34 | 35 | 36 | 37 | 38 | {{ getMeasurementTypeLabel(data.value) }} 39 | 40 | 41 | 42 | {{ getNormalizedFloat(data.value) }} 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /web_server/client/src/config.js: -------------------------------------------------------------------------------- 1 | const Configuration = { 2 | API_BASE_URL: process.env.NODE_ENV === "development" ? "http://localhost:5000/api" : '/api', 3 | DEBUG: process.env.NODE_ENV === "development", 4 | PAGE_SIZE: 8, 5 | DEFAULT_MIN_TIMESTAMP_RANGE: 4 * 60 * 60 * 1000, //4 hours in milliseconds 6 | MAX_CHART_POINTS: 50, 7 | BREAK_POINT_VALUE: 7.5 * 60, // 7.5 minute in seconds 8 | TOAST_DISPLAY_TIME_IN_MS: 20 * 1000, // 20 seconds in milliseconds 9 | }; 10 | 11 | export default Configuration; -------------------------------------------------------------------------------- /web_server/client/src/i18n.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueI18n from 'vue-i18n' 3 | 4 | 5 | Vue.use(VueI18n); 6 | 7 | 8 | const messages = { 9 | en: { 10 | title: "Wizzdev IoT", 11 | loadingInfo: "Still loading data. Please wait ...", 12 | cannotAddNewDevice: "Cannot add new device", 13 | cannotGetList: "Cannot communicate with API. Please try again later.", 14 | devices: "Devices", 15 | deviceID: "Device ID", 16 | deviceDescription: "Description", 17 | deviceGroup: "Group", 18 | deviceType: "Type", 19 | deviceGroups: "Device Groups", 20 | deviceTypes: "Device Types", 21 | deviceGroupDescription: "Device Group Description", 22 | deviceTypeDescription: "Device Type Description", 23 | cannotGetListOfDeviceGroups: "Cannot get list of device groups. Please try again later.", 24 | measurements: "Measurements", 25 | measurementType: "Measurement Type", 26 | measurementDate: "Read Date", 27 | measurementValue: "Read Value", 28 | } 29 | }; 30 | const i18n = new VueI18n({ 31 | locale: 'en', messages 32 | }); 33 | 34 | 35 | export default i18n -------------------------------------------------------------------------------- /web_server/client/src/main.js: -------------------------------------------------------------------------------- 1 | import 'vue-material/dist/vue-material.min.css' 2 | import 'vue-material/dist/theme/black-green-light.css' 3 | import '../static/material_icons.css' 4 | import 'bootstrap/dist/css/bootstrap.css' 5 | import 'bootstrap-vue/dist/bootstrap-vue.css' 6 | 7 | import Vue from "vue"; 8 | import Vuex from "vuex"; 9 | import VueMaterial from 'vue-material'; 10 | import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' 11 | import Toasted from "vue-toasted"; 12 | 13 | import Configuration from "./config"; 14 | import App from "./App.vue"; 15 | import i18n from "./i18n"; 16 | import store from "./store"; 17 | import router from "./router"; 18 | 19 | Vue.config.productionTip = false; 20 | 21 | Vue.use(Vuex); 22 | Vue.use(VueMaterial); 23 | Vue.use(BootstrapVue); 24 | Vue.use(IconsPlugin); 25 | Vue.use(Toasted); 26 | 27 | new Vue({ 28 | router, store, i18n, render: (h) => h(App), 29 | }).$mount("#app"); -------------------------------------------------------------------------------- /web_server/client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter from "vue-router"; 3 | 4 | import DashboardView from "./views/dashboard.view" 5 | import DeviceTableView from "./views/devices.table.view" 6 | import DeviceViewMeasurements from "./views/device.measurements.view" 7 | import DeviceViewConfiguration from "./views/device.configuration.view" 8 | 9 | 10 | Vue.use(VueRouter); 11 | 12 | 13 | const routes = [ 14 | { 15 | path: "/", 16 | name: "Dashboard", 17 | component: DashboardView, 18 | }, 19 | { 20 | path: "/devices/", 21 | name: "Devices", 22 | component: DeviceTableView, 23 | }, 24 | { 25 | path: "/devices/:id", 26 | redirect: "/devices/:id/measurements" 27 | }, 28 | { 29 | path: "/devices/:id/measurements", 30 | name: "Device-Measurements", 31 | component: DeviceViewMeasurements, 32 | }, 33 | { 34 | path: "/devices/:id/configuration", 35 | name: "Device-Configuration", 36 | component: DeviceViewConfiguration, 37 | } 38 | ]; 39 | const router = new VueRouter({ 40 | mode: "history", 41 | base: process.env.BASE_URL, 42 | routes, 43 | }); 44 | 45 | export default router; 46 | -------------------------------------------------------------------------------- /web_server/client/src/store/actions.js: -------------------------------------------------------------------------------- 1 | import ApiHelper from "../common/api.helper"; 2 | 3 | class Actions { 4 | constructor (baseURL) { 5 | this.apiHelper = new ApiHelper(baseURL); 6 | } 7 | 8 | getBase () { 9 | return { 10 | loadItems: async (context) => this.apiHelper.getList(context, "ADD_ITEMS"), 11 | loadItem: async (context, itemID) => this.apiHelper.getList(context, "ADD_ITEM", {suffix: itemID}) 12 | }; 13 | } 14 | } 15 | 16 | export default Actions; 17 | -------------------------------------------------------------------------------- /web_server/client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | 4 | import device from './modules/device'; 5 | import measurement from './modules/measurement'; 6 | import measurementType from './modules/measurement.type'; 7 | import deviceGroup from './modules/device.group'; 8 | import deviceType from './modules/device.type'; 9 | import deviceShadow from './modules/device.shadow'; 10 | 11 | Vue.use(Vuex); 12 | 13 | export default new Vuex.Store({ 14 | modules: {device, measurement, measurementType, deviceGroup, deviceType, deviceShadow}, 15 | }); 16 | -------------------------------------------------------------------------------- /web_server/client/src/store/modules/device.group.js: -------------------------------------------------------------------------------- 1 | import GenericMutations from "../mutations"; 2 | import Actions from "../actions"; 3 | 4 | export default { 5 | namespaced: true, 6 | state: {items: []}, 7 | getters: { 8 | getAllDeviceGroups: (state) => state.items 9 | }, 10 | mutations: (new GenericMutations()).getCrud(), 11 | actions: (new Actions("/DeviceGroup/")).getBase(), 12 | } -------------------------------------------------------------------------------- /web_server/client/src/store/modules/device.js: -------------------------------------------------------------------------------- 1 | import Mutations from "../mutations"; 2 | import Actions from "../actions"; 3 | 4 | export default { 5 | namespaced: true, 6 | state: {items: [],}, 7 | getters: { 8 | getDevice: (state) => (deviceID) => state.items.filter(device => device.device_id === deviceID)[0] || null, 9 | getAllDevices: (state) => state.items, 10 | getAllDevicesByGroup: (state) => (groupName) => { 11 | return state.items.filter(device => (device.device_group === groupName)) 12 | }, 13 | getAllDevicesByType: (state) => (typeName) => { 14 | return state.items.filter(device => (device.device_type === typeName)) 15 | }, 16 | getAllDevicesByGroupAndType: (state) => (groupName, typeName) => { 17 | return state.items.filter(device => (device.device_type === typeName) && (device.device_group === groupName)) 18 | } 19 | }, 20 | mutations: (new Mutations()).getCrud(), 21 | actions: (new Actions('/Device/')).getBase() 22 | } 23 | 24 | -------------------------------------------------------------------------------- /web_server/client/src/store/modules/device.shadow.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import Actions from "../actions"; 4 | 5 | 6 | const DeviceShadowActions = new Actions('/Device/'); 7 | 8 | 9 | export default { 10 | namespaced: true, 11 | state: {items: ({}),}, 12 | getters: { 13 | getDeviceShadow: (state) => (deviceID) => state.items[deviceID] || undefined, 14 | getAllDeviceShadows: (state) => state.items 15 | }, 16 | mutations: { 17 | ADD_ONE_ITEM: (state, {itemKey, data}) => { 18 | Vue.set(state.items, itemKey, data); 19 | } 20 | }, 21 | actions: { 22 | loadDeviceShadow: async (context, deviceID) => { 23 | return 1 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /web_server/client/src/store/modules/device.type.js: -------------------------------------------------------------------------------- 1 | import GenericMutations from "../mutations"; 2 | import Actions from "../actions"; 3 | 4 | export default { 5 | namespaced: true, 6 | state: {items: []}, 7 | getters: { 8 | getAllDeviceTypes: (state) => state.items 9 | }, 10 | mutations: (new GenericMutations()).getCrud(), 11 | actions: (new Actions("/DeviceType/")).getBase(), 12 | } -------------------------------------------------------------------------------- /web_server/client/src/store/modules/measurement.js: -------------------------------------------------------------------------------- 1 | import Actions from "../actions"; 2 | import utils from "../../common/utils"; 3 | 4 | const MeasurementsActions = new Actions('/Measurement/'); 5 | 6 | export default { 7 | namespaced: true, 8 | state: {items: []}, 9 | getters: { 10 | getAllMeasurements: (state) => state.items, 11 | getAllMeasurementsByDevice: (state, getters) => (deviceID) => { 12 | return getters.getAllMeasurements.filter(m => m.device_id === deviceID) 13 | }, 14 | getAllMeasurementsByType: (state, getters) => (measurementType) => { 15 | return getters.getAllMeasurements.filter(m => { 16 | return m.measurement_type === measurementType 17 | }) 18 | }, 19 | getMeasurementsByTypeAndDevice: (state, getters) => (deviceID, measurementType) => { 20 | return getters.getAllMeasurements.filter(m => (m.device_id === deviceID && m.measurement_type === measurementType)) 21 | }, 22 | getAllMeasurementsFromSelectedTime: (state, getters) => (minTimestamp, maxTimestamp) => { 23 | return getters.getAllMeasurements.filter(m => (minTimestamp < m.timestamp) && (m.timestamp < maxTimestamp)) 24 | }, 25 | getAllMeasurementsByDeviceFromSelectedTime: (state, getters) => (deviceID, minTimestamp, maxTimestamp) => { 26 | return getters.getAllMeasurementsByDevice(deviceID).filter(m => (minTimestamp < m.timestamp) && (m.timestamp < maxTimestamp)); 27 | }, 28 | getAllMeasurementsByTypeFromSelectedTime: (state, getters) => (measurementType, minTimestamp, maxTimestamp) => { 29 | return getters.getAllMeasurementsByType(measurementType).filter(m => (minTimestamp < m.timestamp) && (m.timestamp < maxTimestamp)) 30 | }, 31 | getMeasurementsByTypeAndDeviceFromSelectedTime: (state, getters) => (deviceID, measurementType, minTimestamp, maxTimestamp) => { 32 | return getters.getMeasurementsByTypeAndDevice(deviceID, measurementType).filter(m => (minTimestamp < m.timestamp) && (m.timestamp < maxTimestamp)) 33 | } 34 | }, 35 | mutations: { 36 | ADD_ITEM: (state, newItem) => state.items.push(newItem), 37 | ADD_ITEMS: (state, newItems) => state.items = newItems, 38 | ADD_ITEMS_FOR_DEVICE: (state, newItems) => { 39 | if (newItems.length > 0) { 40 | let device_id = newItems[0].device_id; 41 | state.items = state.items 42 | .filter(item => item.device_id !== device_id) 43 | .concat(newItems); 44 | } 45 | } 46 | }, 47 | actions: { 48 | loadItems: async (context) => { 49 | const minTimestamp = utils.getDefaultMinTimestamp(); 50 | return MeasurementsActions.apiHelper.getList(context, "ADD_ITEMS", {queryParams: {minTimestamp}}) 51 | }, 52 | loadMeasurementsForDevice: async (context, {deviceId, minTimestamp, maxTimestamp}) => { 53 | return MeasurementsActions.apiHelper.getList(context, "ADD_ITEMS_FOR_DEVICE", { 54 | queryParams: {minTimestamp: minTimestamp, maxTimestamp: maxTimestamp}, 55 | suffix: deviceId + "/", 56 | skipFilter: true 57 | }) 58 | } 59 | } 60 | } 61 | 62 | 63 | -------------------------------------------------------------------------------- /web_server/client/src/store/modules/measurement.type.js: -------------------------------------------------------------------------------- 1 | import GenericMutations from "../mutations"; 2 | import Actions from "../actions"; 3 | 4 | export default { 5 | namespaced: true, 6 | state: {items: []}, 7 | getters: { 8 | getAllMeasurementTypes: (state) => state.items.sort((m1, m2) => m2.priority - m1.priority), 9 | getMeasurementTypeLabel: (state) => (measurementTypeName) => { 10 | let label = state.items.filter(mt => mt.name === measurementTypeName)[0].label; 11 | if (label) return label; 12 | return measurementTypeName; 13 | } 14 | }, 15 | mutations: (new GenericMutations()).getCrud(), 16 | actions: (new Actions("/MeasurementType/")).getBase(), 17 | } -------------------------------------------------------------------------------- /web_server/client/src/store/mutations.js: -------------------------------------------------------------------------------- 1 | class GenericMutations { 2 | getCrud () { 3 | return ({ 4 | ADD_ITEM: (state, newItem) => state.items.push(newItem), 5 | ADD_ITEMS: (state, newItems) => state.items = state.items.concat(newItems).filter( 6 | (item, index, self) => { 7 | const _item = JSON.stringify(item); 8 | return index === self.findIndex(obj => JSON.stringify(obj) === _item); 9 | } 10 | ) 11 | }); 12 | } 13 | } 14 | 15 | export default GenericMutations; 16 | -------------------------------------------------------------------------------- /web_server/client/src/views/dashboard.view.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Measurements - Charts 6 | 7 | 8 | 9 | 10 | 11 | All Devices 12 | 13 | launch 14 | 15 | 16 | 17 | 18 | 19 | Last Measurements 20 | 21 | 22 | 23 | 24 | 25 | 26 | 36 | 37 | -------------------------------------------------------------------------------- /web_server/client/src/views/device.configuration.view.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | {{ key }}: {{value}} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 79 | 80 | 85 | -------------------------------------------------------------------------------- /web_server/client/src/views/device.view.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ prettyPrint(deviceId) }} 7 | {{ prettyPrint(device ? device.device_group : "" ) }} 8 | {{ prettyPrint(device ? device.device_type : "" ) }} 9 | 10 | 11 | 12 | 13 | 14 | insert_chart_outlined 15 | Measurements 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 44 | -------------------------------------------------------------------------------- /web_server/client/src/views/devices.table.view.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devices 5 | 6 | 7 | 8 | Filters 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 57 | 58 | -------------------------------------------------------------------------------- /web_server/images/logo_wizzdev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/web_server/images/logo_wizzdev.png -------------------------------------------------------------------------------- /web_server/images/logo_wizzdev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /web_server/misc/fill_with_dummy_data.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import string 3 | import random 4 | import os 5 | 6 | from service.device_service import DeviceService 7 | from service.measurement_service import MeasurementService, MeasurementTypeService 8 | 9 | os.environ["DATABASE_HOST"] = "http://localhost:4567" 10 | 11 | # Please consider using batch_write when you are trying to add more than 20 devices once 12 | dummy_devices_count = 10 13 | dummy_devices_group_count = 3 14 | dummy_measurement_type_count = 3 15 | dummy_measurement_count_per_device = 1000 16 | 17 | 18 | # Measurement types 19 | dummy_measurement_types = [] 20 | for j in range(dummy_measurement_type_count): 21 | measurement_type_name = f"measurement_type_{string.ascii_letters[j:j + 2]}" 22 | dummy_measurement_types.append(measurement_type_name) 23 | MeasurementTypeService.create_measurement_type_if_not_exist(name=measurement_type_name) 24 | 25 | # Devices 26 | for i in range(dummy_devices_count): 27 | print(f"{i * 100 // dummy_devices_count}%") 28 | # Devices 29 | device_id = f"dev_{(string.ascii_letters[i] * 3).upper()}" 30 | device_group_name = f"group_{string.ascii_letters[(i % 3) + 1] * 3}" 31 | try: 32 | DeviceService.create_device( 33 | device_id=device_id, 34 | description=f"Description for {device_id}", 35 | device_group=device_group_name, 36 | device_type="Fictitious") 37 | except: 38 | pass 39 | dummy_measurements = [] 40 | # Measurement 41 | start_measurement = random.randint(1, 9) 42 | for j in range(dummy_measurement_count_per_device): 43 | date = datetime.datetime.utcnow() - datetime.timedelta(minutes=10 * (dummy_measurement_count_per_device - j)) 44 | measurement_type = dummy_measurement_types[j % dummy_measurement_type_count] 45 | value = start_measurement + (random.randint(0, 8)) - 4 46 | MeasurementService.create_measurement( 47 | device_id=device_id, value=value, 48 | timestamp=int(round(date.timestamp() * 1000)), measurement_type=measurement_type 49 | ) 50 | -------------------------------------------------------------------------------- /web_server/server/app.py: -------------------------------------------------------------------------------- 1 | import http 2 | import os 3 | 4 | import sentry_sdk 5 | import flask 6 | import flask_restx 7 | import flask_lambda 8 | import marshmallow 9 | import pynamodb.exceptions 10 | from flask_jwt_extended import JWTManager 11 | 12 | import core.response_factory 13 | import config 14 | import common.errors 15 | import views.device_view_set 16 | import views.device_group_view_set 17 | import views.device_type_view_set 18 | import views.measurement_type_view_set 19 | import views.measurement_view_set 20 | import views.auth_view 21 | 22 | authorizations = { 23 | 'Bearer Auth': { 24 | 'type': 'apiKey', 25 | 'in': 'header', 26 | 'name': 'Authorization' 27 | }, 28 | } 29 | app = flask_lambda.FlaskLambda(__name__) 30 | api = flask_restx.Api(app, version='1.0', title="Wizzdev IoT API", prefix="/api", doc="/api/", security='Bearer Auth', 31 | authorizations=authorizations) 32 | 33 | api.add_namespace(views.device_view_set.device_namespace) 34 | api.add_namespace(views.device_type_view_set.device_type_namespace) 35 | api.add_namespace(views.device_group_view_set.device_group_namespace) 36 | api.add_namespace(views.measurement_type_view_set.measurement_type_namespace) 37 | api.add_namespace(views.measurement_view_set.measurement_namespace) 38 | api.add_namespace(views.auth_view.auth_namespace) 39 | 40 | app.config['JWT_SECRET_KEY'] = config.SECRET_KEY 41 | jwt = JWTManager(app) 42 | 43 | sentry_dsn = os.environ.get('SENTRY') 44 | if sentry_dsn: 45 | sentry_sdk.init(sentry_dsn, environment=os.environ.get("MODE", "undefined")) 46 | 47 | 48 | @jwt.user_claims_loader 49 | def add_claims_to_access_token(access_level): 50 | return {'access_level': access_level} 51 | 52 | 53 | @app.errorhandler(marshmallow.ValidationError) 54 | def handle_payload_validation_error(error): 55 | return core.response_factory.create_invalid_request_data_response(status_text=str(error)) 56 | 57 | 58 | @app.errorhandler(common.errors.ItemDoesNotExist) 59 | @app.errorhandler(pynamodb.exceptions.DoesNotExist) 60 | def handle_item_does_not_exist_error(error): 61 | return core.response_factory.create_not_found_response() 62 | 63 | 64 | @app.errorhandler(Exception) 65 | def handle_all_exceptions(error): 66 | return core.response_factory.create_failed_response(status_text=str(error)) 67 | 68 | 69 | @app.after_request 70 | def add_no_robots_html_header(response: flask.Response): 71 | if config.NO_ROBOTS: 72 | response.headers['X-Robots-Tag'] = "noindex, nofollow, noarchive" 73 | if config.CORS: 74 | response.headers['Access-Control-Allow-Origin'] = "*" 75 | return response 76 | 77 | 78 | @app.route('/') 79 | def get_website() -> flask.Response: 80 | return flask.send_file(os.path.join("client", "index.html")) 81 | 82 | 83 | @app.route('/js/') 84 | def get_js_resource(path) -> flask.Response: 85 | return flask.send_file(os.path.join('client', 'js', path)) 86 | 87 | 88 | @app.route('/css/') 89 | def get_css_resource(path) -> flask.Response: 90 | return flask.send_file(os.path.join('client', 'css', path)) 91 | 92 | 93 | @app.route('/favicon.ico') 94 | def get_favicon(): 95 | return flask.send_file(os.path.join("client", "favicon.png")) 96 | 97 | 98 | @app.route('/#') 99 | def redirect_front(path=None): 100 | return flask.redirect("/") 101 | -------------------------------------------------------------------------------- /web_server/server/client/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/web_server/server/client/.keep -------------------------------------------------------------------------------- /web_server/server/client/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/web_server/server/client/favicon.png -------------------------------------------------------------------------------- /web_server/server/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | SECRET_KEY = os.environ.get("SECRET_KEY", "secretX@0486791020945248") 5 | PAGE_SIZE = int(os.environ.get('PAGE_SIZE', 15)) 6 | NO_ROBOTS = bool(os.environ.get('NO_ROBOTS', True)) # Define if page should be indexed 7 | CORS = bool(os.environ.get('CORS', True)) 8 | ENV_LOGIN = os.environ.get('ESP_HARD_LOGIN', 'DEBUG_LOGIN') 9 | ENV_PASSWORD = os.environ.get('ESP_HARD_PASSWORD', 'DEBUG_PASSWORD') 10 | AWS_ROOT_CA_CERTIFICATE_URL = "https://www.amazontrust.com/repository/AmazonRootCA1.pem" 11 | AWS_REGION = os.environ.get('API_REGION_AWS') 12 | AWS_BASE_THING_TYPE = os.environ.get('THING_TYPE_BASE_AWS') 13 | AWS_BASE_THING_POLICY = os.environ.get('THING_POLICY_BASE_AWS') 14 | -------------------------------------------------------------------------------- /web_server/server/core/certificates_helper.py: -------------------------------------------------------------------------------- 1 | import config 2 | 3 | import requests 4 | import os 5 | 6 | 7 | class CertificatesHandler: 8 | 9 | def __init__(self, client): 10 | self.client = client 11 | self.__certificates = {} 12 | self.cert_arn = None 13 | 14 | def get_certificates(self): 15 | cert_response = self.client.create_keys_and_certificate(setAsActive=True) 16 | self.__certificates['PrivateKey'] = cert_response['keyPair']['PrivateKey'] 17 | self.__certificates['certificatePem'] = cert_response['certificatePem'] 18 | 19 | # Save cert_arn for the future 20 | self.cert_arn = cert_response['certificateArn'] 21 | 22 | self.get_ca_cert() 23 | return self.__certificates 24 | 25 | def get_ca_cert(self): 26 | ca_cert_response = requests.get(config.AWS_ROOT_CA_CERTIFICATE_URL) 27 | self.__certificates['certificateCa'] = ca_cert_response.content.decode("utf-8") 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web_server/server/core/request_arguments_parser.py: -------------------------------------------------------------------------------- 1 | from flask_restx.reqparse import RequestParser 2 | 3 | core_request_arguments_parser = RequestParser() 4 | core_request_arguments_parser.add_argument("limit", type=int, location="args") 5 | 6 | -------------------------------------------------------------------------------- /web_server/server/core/response_factory.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import flask 4 | 5 | 6 | def _create_common_response(data, status: int, status_text: str): 7 | response_dict = { 8 | 'data': data, 9 | 'status': status, 10 | 'status_text': status_text, 11 | } 12 | return flask.jsonify(response_dict) 13 | 14 | 15 | def create_success_response(data: dict or list, status: int = None, status_text: str = None): 16 | return _create_common_response(data=data, 17 | status=status or HTTPStatus.OK.value, 18 | status_text=status_text or HTTPStatus.OK.description) 19 | 20 | 21 | def create_success_plain_response(status_text: str = None): 22 | return flask.Response(status_text or HTTPStatus.NO_CONTENT.description, 23 | status=HTTPStatus.NO_CONTENT.value, 24 | mimetype='text/plain') 25 | 26 | 27 | def create_failed_response(status_text: str = None): 28 | return flask.Response(status_text or HTTPStatus.INTERNAL_SERVER_ERROR.description, 29 | status=HTTPStatus.INTERNAL_SERVER_ERROR.value, 30 | mimetype='text/plain') 31 | 32 | 33 | def create_not_found_response(): 34 | return flask.Response(HTTPStatus.NOT_FOUND.description, 35 | status=HTTPStatus.NOT_FOUND.value, 36 | mimetype='text/plain') 37 | 38 | 39 | def create_invalid_request_data_response(status_text=None): 40 | return flask.Response(status_text or HTTPStatus.BAD_REQUEST.description, 41 | status=HTTPStatus.BAD_REQUEST.description, 42 | mimetype='text/plain') 43 | -------------------------------------------------------------------------------- /web_server/server/core/serializer.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import collections 3 | 4 | import flask_restx 5 | import flask_restx.fields as frf 6 | import marshmallow.fields as mf 7 | from marshmallow_pynamodb import ModelSchema 8 | 9 | from model.base_model import Model 10 | from common.util import create_table 11 | 12 | 13 | class Serializer(ModelSchema): 14 | _api_model = None 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(Serializer, self).__init__(*args, **kwargs) 18 | create_table(self.model()) 19 | 20 | @property 21 | def api_model(self): 22 | if self._api_model is None: 23 | self._api_model = self._get_api_model() 24 | return self._api_model 25 | 26 | def loads_required(self, json_data: str, many: bool = False): 27 | data = self.loads(json_data=json_data, many=many).attribute_values 28 | if many: 29 | return [self._remove_additional_fields(data_entry) for data_entry in data] 30 | return self._remove_additional_fields(data) 31 | 32 | def serialize(self, obj, *, many: bool = False): 33 | return super(Serializer, self)._serialize(obj, many=many) 34 | 35 | def _remove_additional_fields(self, data: dict): 36 | """ Remove fields that aren't provided by user in request """ 37 | return {k: v for k, v in data.items() if k in self.declared_fields} 38 | 39 | def _get_api_model(self): 40 | """ Map marshmallow schema into flask_restx api model """ 41 | model_name = self.model().__name__.replace("Model", "") 42 | rest_attributes = collections.OrderedDict() 43 | for key, value in self.declared_fields.items(): 44 | rest_attributes[key] = self.map_marshmallow_field_to_api_field(value) 45 | return flask_restx.Model(model_name, rest_attributes) 46 | 47 | @classmethod 48 | def model(cls) -> t.Type[Model]: 49 | """ Expose PynamoDB Model """ 50 | return cls.Meta.model 51 | 52 | @classmethod 53 | def map_marshmallow_field_to_api_field(cls, marshmallow_field: mf.Field): 54 | if isinstance(marshmallow_field, mf.String): 55 | return frf.String() 56 | if isinstance(marshmallow_field, (mf.Raw, mf.Mapping, mf.Dict)): 57 | return frf.Raw() 58 | if isinstance(marshmallow_field, (mf.List, mf.Tuple)): 59 | return frf.List(cls.map_marshmallow_field_to_api_field(marshmallow_field.inner)) 60 | if isinstance(marshmallow_field, (mf.Number, mf.Integer, mf.Decimal, mf.Int)): 61 | return frf.Integer() 62 | if isinstance(marshmallow_field, (mf.Boolean, mf.Bool)): 63 | return frf.Boolean() 64 | if isinstance(marshmallow_field, mf.Float): 65 | return frf.Float() 66 | if isinstance(marshmallow_field, mf.Date): 67 | return frf.Date() 68 | if isinstance(marshmallow_field, mf.DateTime): 69 | return frf.DateTime() 70 | if isinstance(marshmallow_field, (mf.Url, mf.URL)): 71 | return frf.Url() 72 | raise Exception(f"Cannot map {marshmallow_field} to API model field") 73 | 74 | 75 | def serializer_factory(model_class: t.Type[Model]): 76 | class _Serializer(Serializer): 77 | is_removed = mf.Boolean(default=False) 78 | created_at = mf.Float(allow_none=True) 79 | 80 | class Meta: 81 | model = model_class 82 | 83 | return _Serializer 84 | -------------------------------------------------------------------------------- /web_server/server/core/things_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | import boto3 4 | import requests 5 | 6 | import config 7 | from botocore.config import Config as boto_config 8 | from core.certificates_helper import CertificatesHandler 9 | 10 | aws_config = boto_config(region_name=config.AWS_REGION) 11 | thing_client = boto3.client('iot', config=aws_config) 12 | 13 | 14 | def create_thing(device_data: dict): 15 | thing_data = get_thing_data_from_device_data(device_data) 16 | delete_thing_if_exist(thing_data['thingName']) 17 | create_response = thing_client.create_thing(**thing_data, thingTypeName=config.AWS_BASE_THING_TYPE) 18 | 19 | 20 | def get_thing_data_from_device_data(device_data: dict) -> dict: 21 | """ 22 | Device parameters are mapped into things parameters: 23 | - device_id == thingName 24 | - device_type == thingType 25 | - settings == attributes 26 | Merge is always false 27 | """ 28 | thing_data = {} 29 | thing_data['thingName'] = device_data.get('device_id') 30 | thing_data['attributePayload'] = {} 31 | thing_data['attributePayload']['attributes'] = device_data.get('settings') 32 | thing_data['attributePayload']['merge'] = False 33 | return thing_data 34 | 35 | 36 | def create_thing_type_if_not_exist(thing_type: str): 37 | warnings.warn("API isn't responsible for creating new thing types anymore", DeprecationWarning) 38 | return 39 | 40 | 41 | def create_billing_group_if_not_exist(billing_group: str): 42 | warnings.warn("API isn't responsible for creating new billing groups anymore", DeprecationWarning) 43 | return 44 | 45 | 46 | def delete_thing_if_exist(thing_name: str): 47 | try: 48 | thing_client.describe_thing(thingName=thing_name) 49 | except thing_client.exceptions.ResourceNotFoundException as e: 50 | thing_client.delete_thing(thingName=thing_name) 51 | 52 | 53 | def get_thing_certificates(thing_name: str): 54 | certs_handler = CertificatesHandler(thing_client) 55 | certs = certs_handler.get_certificates() 56 | remove_old_thing_certificates(thing_name) 57 | thing_client.attach_thing_principal(thingName=thing_name, principal=certs_handler.cert_arn) 58 | thing_client.attach_principal_policy(principal=certs_handler.cert_arn, policyName=config.AWS_BASE_THING_POLICY) 59 | return certs 60 | 61 | 62 | def remove_old_thing_certificates(thing_name: str): 63 | old_certs = thing_client.list_thing_principals(thingName=thing_name) 64 | for principal in old_certs.get('principals'): 65 | thing_client.detach_thing_principal(thingName=thing_name, principal=principal) -------------------------------------------------------------------------------- /web_server/server/core/user_service.py: -------------------------------------------------------------------------------- 1 | from service.base_service import BaseService 2 | from model.user_model import UserLoginModel 3 | from werkzeug.security import generate_password_hash 4 | from flask_jwt_extended import create_access_token 5 | 6 | import config 7 | 8 | 9 | class UserService(BaseService): 10 | model_class = UserLoginModel 11 | 12 | @classmethod 13 | def get_user_auth(cls, username: str, password: str, **kwargs) -> bool: 14 | 15 | if username == config.ENV_LOGIN and password == config.ENV_PASSWORD: 16 | return True 17 | try: 18 | user = cls.get(hash_key=username) 19 | user_password_hash = user.password 20 | return user_password_hash == cls.get_password_hash(password) 21 | except cls.model_class.DoesNotExist: 22 | return False 23 | 24 | @classmethod 25 | def get_token(cls, username: str) -> str: 26 | access_token = create_access_token(identity=username) 27 | return access_token 28 | 29 | @classmethod 30 | def get_password_hash(cls, plain_password: str) -> str: 31 | return generate_password_hash(plain_password) 32 | -------------------------------------------------------------------------------- /web_server/server/core/utils.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import typing 3 | 4 | from config import PAGE_SIZE 5 | from service.base_service import BaseService 6 | from core.request_arguments_parser import core_request_arguments_parser 7 | 8 | 9 | def add_no_robots_html_header(response: flask.Response): 10 | response.headers['X-Robots-Tag'] = "noindex, nofollow, noarchive" 11 | return response 12 | 13 | 14 | def scan_with_pagination(service: typing.Type[BaseService], **kwargs): 15 | args = core_request_arguments_parser.parse_args() 16 | return service.scan(limit=args.limit or PAGE_SIZE, **kwargs) 17 | -------------------------------------------------------------------------------- /web_server/server/requirements.txt: -------------------------------------------------------------------------------- 1 | pynamodb==4.3.3 2 | flask==1.1.2 3 | flask-restx==0.2.0 4 | flask_accepts 5 | marshmallow-pynamo-db==0.10.6 6 | awscli==1.18.17 7 | pytest==6.0.2 8 | requests==2.24.0 9 | flask-lambda-support==0.1.5 10 | flask_jwt_extended==3.25.0 11 | sentry_sdk==0.17.4 12 | werkzeug 13 | 14 | -------------------------------------------------------------------------------- /web_server/server/serializers/device_serializer.py: -------------------------------------------------------------------------------- 1 | from core.serializer import serializer_factory 2 | from model.device_model import DeviceModel, DeviceGroupModel, DeviceTypeModel 3 | 4 | 5 | DeviceSerializer = serializer_factory(DeviceModel) 6 | DeviceGroupSerializer = serializer_factory(DeviceGroupModel) 7 | DeviceTypeSerializer = serializer_factory(DeviceTypeModel) 8 | -------------------------------------------------------------------------------- /web_server/server/serializers/measurement_serializer.py: -------------------------------------------------------------------------------- 1 | from core.serializer import serializer_factory 2 | from model.measurement_model import MeasurementTypeModel, MeasurementModel 3 | 4 | 5 | MeasurementSerializer = serializer_factory(MeasurementModel) 6 | MeasurementTypeSerializer = serializer_factory(MeasurementTypeModel) 7 | 8 | -------------------------------------------------------------------------------- /web_server/server/serializers/user_serializer.py: -------------------------------------------------------------------------------- 1 | from core.serializer import serializer_factory 2 | from model.user_model import UserLoginModel 3 | 4 | UserLoginSerializer = serializer_factory(UserLoginModel) 5 | 6 | -------------------------------------------------------------------------------- /web_server/server/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizzdev-pl/iot-starter/329392c594ee75f68a0eeddf057c99e4d9454ca6/web_server/server/views/__init__.py -------------------------------------------------------------------------------- /web_server/server/views/auth_view.py: -------------------------------------------------------------------------------- 1 | import flask_restx 2 | 3 | from serializers.user_serializer import UserLoginSerializer 4 | from core.user_service import UserService 5 | from core.response_factory import * 6 | 7 | login_schema = UserLoginSerializer() 8 | auth_namespace = flask_restx.Namespace("Auth") 9 | auth_namespace.add_model(login_schema.api_model.name, login_schema.api_model) 10 | 11 | @auth_namespace.route('/login') 12 | class UserLoginApi(flask_restx.Resource): 13 | 14 | @auth_namespace.expect(login_schema.api_model) 15 | @auth_namespace.response(HTTPStatus.CREATED.real, "You are successfully log in") 16 | def post(self): 17 | """Log in using username and password""" 18 | auth_data = login_schema.loads_required(flask.request.data) 19 | auth = UserService.get_user_auth(**auth_data) 20 | 21 | if auth: 22 | access_token = UserService.get_token(auth_data['username']) 23 | return create_success_response(data=access_token) 24 | else: 25 | return create_failed_response("Invalid username or password") 26 | -------------------------------------------------------------------------------- /web_server/server/views/device_group_view_set.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import flask 4 | import flask_restx 5 | 6 | from core.utils import scan_with_pagination 7 | from core.request_arguments_parser import core_request_arguments_parser 8 | from core.response_factory import create_success_response, create_success_plain_response 9 | from serializers.device_serializer import DeviceGroupSerializer 10 | from service.device_service import DeviceGroupService 11 | from flask_jwt_extended import jwt_required 12 | 13 | device_group_schema = DeviceGroupSerializer() 14 | device_group_namespace = flask_restx.Namespace("DeviceGroup") 15 | device_group_namespace.add_model(device_group_schema.api_model.name, device_group_schema.api_model) 16 | 17 | 18 | @device_group_namespace.route('/') 19 | class DeviceGroupAllApi(flask_restx.Resource): 20 | 21 | @device_group_namespace.expect(core_request_arguments_parser) 22 | @device_group_namespace.response(HTTPStatus.OK.real, "List of device groups", [device_group_schema.api_model]) 23 | def get(self): 24 | """ Returns list of device groups """ 25 | device_groups = scan_with_pagination(DeviceGroupService) 26 | return create_success_response(data=device_group_schema.serialize(device_groups, many=True)) 27 | 28 | @device_group_namespace.expect(device_group_schema.api_model) 29 | @device_group_namespace.response(HTTPStatus.CREATED.real, 30 | "Device group was successfully created", 31 | device_group_schema.api_model) 32 | @jwt_required 33 | def post(self): 34 | """ Create new device """ 35 | device_group_data = device_group_schema.loads_required(flask.request.data) 36 | device_group = DeviceGroupService.create_device_group(**device_group_data) 37 | return create_success_response(data=device_group_schema.serialize(device_group)) 38 | 39 | 40 | @device_group_namespace.route('/') 41 | class DeviceGroupSelectedApi(flask_restx.Resource): 42 | 43 | @device_group_namespace.response(HTTPStatus.OK.real, "Selected device group", device_group_schema.api_model) 44 | def get(self, hash_key: str): 45 | """ Returns selected device group """ 46 | device_group = DeviceGroupService.get(hash_key) 47 | return create_success_response(data=device_group_schema.serialize(device_group)) 48 | 49 | @device_group_namespace.response(HTTPStatus.NO_CONTENT.real, "Device group was successfully removed") 50 | @jwt_required 51 | def delete(self, hash_key: str): 52 | """ Remove selected device group """ 53 | device_group = DeviceGroupService.get(hash_key) 54 | device_group.safe_delete() 55 | return create_success_plain_response(status_text=f"Device {hash_key} removed") 56 | 57 | @device_group_namespace.expect(device_group_schema.api_model) 58 | @device_group_namespace.response(HTTPStatus.OK.real, "Edited device group", device_group_schema.api_model) 59 | @jwt_required 60 | def put(self, hash_key: str): 61 | """ Edit selected device group """ 62 | device_group_data = device_group_schema.loads_required(flask.request.data) 63 | device_group = DeviceGroupService.put(**device_group_data) 64 | return create_success_response(data=device_group_schema.serialize(device_group)) 65 | -------------------------------------------------------------------------------- /web_server/server/views/device_type_view_set.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import flask 4 | import flask_restx 5 | 6 | from core.utils import scan_with_pagination 7 | from core.request_arguments_parser import core_request_arguments_parser 8 | from core.response_factory import create_success_response, create_success_plain_response 9 | from serializers.device_serializer import DeviceTypeSerializer 10 | from service.device_service import DeviceTypeService 11 | from flask_jwt_extended import jwt_required 12 | 13 | device_type_schema = DeviceTypeSerializer() 14 | device_type_namespace = flask_restx.Namespace("DeviceType") 15 | device_type_namespace.add_model(device_type_schema.api_model.name, device_type_schema.api_model) 16 | 17 | 18 | @device_type_namespace.route('/') 19 | class DeviceTypeAllApi(flask_restx.Resource): 20 | 21 | @device_type_namespace.expect(core_request_arguments_parser) 22 | @device_type_namespace.response(HTTPStatus.OK.real, "List of device types", [device_type_schema.api_model]) 23 | def get(self): 24 | """ Returns list of device types """ 25 | device_types = scan_with_pagination(DeviceTypeService) 26 | return create_success_response(data=device_type_schema.serialize(device_types, many=True)) 27 | 28 | @device_type_namespace.expect(device_type_schema.api_model) 29 | @device_type_namespace.response(HTTPStatus.CREATED.real, 30 | "Device type was successfully created", 31 | device_type_schema.api_model) 32 | @jwt_required 33 | def post(self): 34 | """ Create new device """ 35 | device_type_data = device_type_schema.loads_required(flask.request.data) 36 | device_type = DeviceTypeService.create_device_type(**device_type_data) 37 | return create_success_response(data=device_type_schema.serialize(device_type)) 38 | 39 | 40 | @device_type_namespace.route('/') 41 | class DeviceTypeSelectedApi(flask_restx.Resource): 42 | 43 | @device_type_namespace.response(HTTPStatus.OK.real, "Selected device type", device_type_schema.api_model) 44 | def get(self, hash_key: str): 45 | """ Returns selected device type """ 46 | device_type = DeviceTypeService.get(hash_key) 47 | return create_success_response(data=device_type_schema.serialize(device_type)) 48 | 49 | @device_type_namespace.response(HTTPStatus.NO_CONTENT.real, "Device type was successfully removed") 50 | @jwt_required 51 | def delete(self, hash_key: str): 52 | """ Remove selected device type """ 53 | device_type = DeviceTypeService.get(hash_key) 54 | device_type.safe_delete() 55 | return create_success_plain_response(status_text=f"Device {hash_key} removed") 56 | 57 | @device_type_namespace.expect(device_type_schema.api_model) 58 | @device_type_namespace.response(HTTPStatus.OK.real, "Edited device type", device_type_schema.api_model) 59 | @jwt_required 60 | def put(self, hash_key: str): 61 | """ Edit selected device type """ 62 | device_type_data = device_type_schema.loads_required(flask.request.data) 63 | device_type = DeviceTypeService.put(**device_type_data) 64 | return create_success_response(data=device_type_schema.serialize(device_type)) 65 | -------------------------------------------------------------------------------- /web_server/server/views/measurement_type_view_set.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import flask 4 | import flask_restx 5 | 6 | from core.utils import scan_with_pagination 7 | from core.request_arguments_parser import core_request_arguments_parser 8 | from core.response_factory import create_success_response, create_success_plain_response 9 | from serializers.measurement_serializer import MeasurementTypeSerializer 10 | from service.measurement_service import MeasurementTypeService 11 | 12 | 13 | measurement_type_schema = MeasurementTypeSerializer() 14 | measurement_type_namespace = flask_restx.Namespace("MeasurementType") 15 | measurement_type_namespace.add_model(measurement_type_schema.api_model.name, measurement_type_schema.api_model) 16 | 17 | 18 | @measurement_type_namespace.route('/') 19 | class MeasurementTypeAllApi(flask_restx.Resource): 20 | 21 | @measurement_type_namespace.expect(core_request_arguments_parser) 22 | @measurement_type_namespace.response(HTTPStatus.OK.real, 23 | "List of measurement types", 24 | [measurement_type_schema.api_model]) 25 | def get(self): 26 | """ Returns list of measurement types """ 27 | measurement_types = scan_with_pagination(MeasurementTypeService) 28 | return create_success_response(data=measurement_type_schema.serialize(measurement_types, many=True)) 29 | 30 | @measurement_type_namespace.expect(measurement_type_schema.api_model) 31 | @measurement_type_namespace.response(HTTPStatus.CREATED.real, 32 | "Measurement type was successfully created", 33 | measurement_type_schema.api_model) 34 | def post(self): 35 | """ Create new measurement type """ 36 | measurement_type_data = measurement_type_schema.loads_required(flask.request.data) 37 | measurement_type = MeasurementTypeService.create_measurement_type(**measurement_type_data) 38 | return create_success_response(data=measurement_type_schema.serialize(measurement_type)) 39 | 40 | 41 | @measurement_type_namespace.route('/') 42 | class DeviceSelectedApi(flask_restx.Resource): 43 | 44 | @measurement_type_namespace.response(HTTPStatus.OK.real, 45 | "Selected measurement type", measurement_type_schema.api_model) 46 | def get(self, hash_key: str): 47 | """ Returns selected measurement type """ 48 | measurement_type = MeasurementTypeService.get(hash_key) 49 | return create_success_response(data=measurement_type_schema.serialize(measurement_type)) 50 | 51 | @measurement_type_namespace.response(HTTPStatus.NO_CONTENT.real, "Measurement type was successfully removed") 52 | def delete(self, hash_key: str): 53 | """ Remove selected measurement type """ 54 | measurement_type = MeasurementTypeService.get(hash_key) 55 | measurement_type.safe_delete() 56 | return create_success_plain_response(status_text=f"Measurement type {hash_key} removed") 57 | 58 | @measurement_type_namespace.expect(measurement_type_schema.api_model) 59 | @measurement_type_namespace.response(HTTPStatus.OK.real, 60 | "Edited measurement type", 61 | measurement_type_schema.api_model) 62 | def put(self, hash_key: str): 63 | """ Edit selected measurement type """ 64 | measurement_type_data = measurement_type_schema.loads_required(flask.request.data) 65 | measurement_type = MeasurementTypeService.put(**measurement_type_data) 66 | return create_success_response(data=measurement_type_schema.serialize(measurement_type)) 67 | -------------------------------------------------------------------------------- /web_server/server/wsgi.py: -------------------------------------------------------------------------------- 1 | from app import app 2 | 3 | if __name__ == "__main__": 4 | app.run() 5 | --------------------------------------------------------------------------------
Add new AP
IoT Starter will now connect to the strongest AP available.