diff --git a/ag_data/serializers.py b/ag_data/serializers.py new file mode 100644 index 00000000..4d25948d --- /dev/null +++ b/ag_data/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers + +from ag_data.models import AGEvent, AGSensor, AGMeasurement + + +class AGMeasurementSerializer(serializers.ModelSerializer): + """ + Serializer for the Model AGMeasurement. + """ + + sensor_id = serializers.PrimaryKeyRelatedField( + read_only=False, queryset=AGSensor.objects.all() + ) + event_uuid = serializers.PrimaryKeyRelatedField( + read_only=False, queryset=AGEvent.objects.all() + ) + + class Meta: + model = AGMeasurement + fields = ("timestamp", "sensor_id", "event_uuid", "value") diff --git a/mercury/grafanaAPI/grafana_api.py b/mercury/grafanaAPI/grafana_api.py index 71bebaff..881f019f 100644 --- a/mercury/grafanaAPI/grafana_api.py +++ b/mercury/grafanaAPI/grafana_api.py @@ -93,7 +93,7 @@ def get_dashboard_by_event_name(self, event_name): # If there are spaces in the name, the GF API will replace them with dashes # to generate the "slug". A slug can be used to query the API. endpoint = os.path.join( - self.hostname, "api/dashboards/db", event_name.lower().replace(" ", "-"), + self.hostname, "api/dashboards/db", event_name.lower().replace(" ", "-") ) response = requests.get(url=endpoint, auth=("api_key", self.api_token)) diff --git a/mercury/tests/test_configure_sensors.py b/mercury/tests/test_configure_sensors.py index a4b3cb77..b9818042 100644 --- a/mercury/tests/test_configure_sensors.py +++ b/mercury/tests/test_configure_sensors.py @@ -34,10 +34,7 @@ class TestConfigureSensorView(TestCase): test_sensor_update_object_name = "update_sensor" test_type_update_object_name = "update_type" - test_sensor = { - "name": "wind speed sensor", - "type_id": test_type_object_name, - } + test_sensor = {"name": "wind speed sensor", "type_id": test_type_object_name} test_sensor_type = { "type-name": "fuel level", @@ -57,14 +54,8 @@ def setUp(self): name=self.test_type_object_name, processing_formula=0, format={ - self.field_name_1: { - "data_type": self.data_type_1, - "unit": self.unit_1, - }, - self.field_name_2: { - "data_type": self.data_type_1, - "unit": self.unit_1, - }, + self.field_name_1: {"data_type": self.data_type_1, "unit": self.unit_1}, + self.field_name_2: {"data_type": self.data_type_1, "unit": self.unit_1}, }, ) test_type_object.save() @@ -78,14 +69,8 @@ def setUp(self): name=self.test_type_update_object_name, processing_formula=0, format={ - self.field_name_1: { - "data_type": self.data_type_1, - "unit": self.unit_1, - }, - self.field_name_2: { - "data_type": self.data_type_1, - "unit": self.unit_1, - }, + self.field_name_1: {"data_type": self.data_type_1, "unit": self.unit_1}, + self.field_name_2: {"data_type": self.data_type_1, "unit": self.unit_1}, }, ) diff --git a/mercury/tests/test_grafana.py b/mercury/tests/test_grafana.py index 925cd602..85d2a084 100644 --- a/mercury/tests/test_grafana.py +++ b/mercury/tests/test_grafana.py @@ -354,7 +354,7 @@ def test_create_postgres_datasource(self): # Query new datasource endpoint = os.path.join( - self.grafana.endpoints["datasource_name"], self.datasource_name, + self.grafana.endpoints["datasource_name"], self.datasource_name ) headers = {"Content-Type": "application/json"} response = requests.get( @@ -388,7 +388,7 @@ def test_delete_postgres_datasource(self): # confirm that the datasource was actually deleted by querying it endpoint = os.path.join( - self.grafana.endpoints["datasource_name"], self.datasource_name, + self.grafana.endpoints["datasource_name"], self.datasource_name ) headers = {"Content-Type": "application/json"} response = requests.get( diff --git a/mercury/tests/test_measurement.py b/mercury/tests/test_measurement.py new file mode 100644 index 00000000..2d6083a4 --- /dev/null +++ b/mercury/tests/test_measurement.py @@ -0,0 +1,85 @@ +import datetime + +import mock +from django.test import TestCase +from rest_framework.reverse import reverse + +from ag_data.models import AGEvent, AGVenue + + +def fake_venue(): + return AGVenue( + uuid="d81cac8d-26e1-4983-a942-1922e54a943a", + name="fake venue", + description="fake venue", + latitude=111.11, + longitude=111.11, + ) + + +def fake_event(uuid): + """ + Mock a dummy AGEvent model + """ + return AGEvent( + uuid=uuid, + name="fake event", + description="fake event", + date=datetime.datetime(2020, 2, 2, 20, 21, 22), + venue_uuid=fake_venue(), + ) + + +def fake_valid(res, raise_exception=True): + return True + + +class TestMeasurement(TestCase): + def setUp(self) -> None: + self.post_url = "mercury:measurement" + self.uuid = "d81cac8d-26e1-4983-a942-1922e54a943d" + self.uuid2 = "d81cac8d-26e1-4983-a942-1922e54a943a" + + def post_radio_data(self): + # POST sensor data to the measurement url + response = self.client.post( + reverse(self.post_url, args=[self.uuid]), + data={ + "sensor_id": 1, + "values": {"power": "2", "speed": 1}, + "date": datetime.datetime(2020, 2, 2, 20, 21, 22), + }, + ) + return response + + def post_defect_data(self): + response = self.client.post( + reverse(self.post_url, args=[self.uuid]), + data={ + "values": {"power": "2", "speed": 1}, + "date": datetime.datetime(2020, 2, 2, 20, 21, 22), + }, + ) + return response + + def test_Radio_Receiver_POST_Event_Not_Exist(self): + response = self.client.post(reverse(self.post_url, args=[self.uuid2])) + self.assertEqual(404, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + def test_Radio_Receiver_POST_Missing_Params(self): + response = self.post_defect_data() + self.assertEqual(400, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + def test_Radio_Receiver_POST_Fail_to_Save(self): + response = self.post_radio_data() + self.assertEqual(400, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + @mock.patch("ag_data.serializers.AGMeasurementSerializer.is_valid", fake_valid) + @mock.patch("ag_data.serializers.AGMeasurementSerializer.save", fake_valid) + @mock.patch("ag_data.serializers.AGMeasurementSerializer.data", "") + def test_Radio_Receiver_POST_Event_Success(self): + response = self.post_radio_data() + self.assertEqual(201, response.status_code) diff --git a/mercury/tests/test_radio_receiver.py b/mercury/tests/test_radio_receiver.py new file mode 100644 index 00000000..08c1649d --- /dev/null +++ b/mercury/tests/test_radio_receiver.py @@ -0,0 +1,132 @@ +from django.test import TestCase +from django.urls import reverse +import datetime +import mock + +from ag_data.models import AGEvent, AGVenue + + +def fake_venue(): + return AGVenue( + uuid="d81cac8d-26e1-4983-a942-1922e54a943a", + name="fake venue", + description="fake venue", + latitude=111.11, + longitude=111.11, + ) + + +def fake_event(uuid): + """ + Mock a dummy AGEvent model + """ + return AGEvent( + uuid=uuid, + name="fake event", + description="fake event", + date=datetime.datetime(2020, 2, 2, 20, 21, 22), + venue_uuid=fake_venue(), + ) + + +def fake_valid(res): + return True + + +def fake_valid_port(): + return ["dev/tty.USB"] + + +def fake_invalid_port(): + return [] + + +class TestRadioReceiverView(TestCase): + def setUp(self) -> None: + self.get_url = "mercury:radioreceiver" + self.post_url = "mercury:radioreceiver" + self.uuid = "d81cac8d-26e1-4983-a942-1922e54a943d" + self.uuid2 = "d81cac8d-26e1-4983-a942-1922e54a943a" + + def test_Radio_Receiver_GET_No_Related_Event(self): + response = self.client.get(reverse(self.get_url, args=[self.uuid2])) + self.assertEqual(404, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + def test_Radio_Receiver_GET_Missing_Enable(self): + response = self.client.get( + reverse(self.get_url, args=[self.uuid]), + data={ + "baudrate": 9000, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 1, + }, + ) + self.assertEqual(400, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + @mock.patch("mercury.views.radioreceiver.serial_ports", fake_invalid_port) + def test_Radio_Receiver_GET_No_Valid_Port(self): + response = self.client.get( + reverse(self.get_url, args=[self.uuid]), + data={ + "enable": 1, + "baudrate": 9000, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 1, + }, + ) + self.assertEqual(503, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + @mock.patch("mercury.views.radioreceiver.serial_ports", fake_valid_port) + def test_Radio_Receiver_GET_Success(self): + response = self.client.get( + reverse(self.get_url, args=[self.uuid]), + data={ + "enable": 1, + "baudrate": 9000, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 1, + }, + ) + self.assertEqual(200, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + @mock.patch("mercury.views.radioreceiver.serial_ports", fake_valid_port) + @mock.patch("mercury.views.radioreceiver.check_port", fake_valid) + def test_Radio_Receiver_GET_Close_Port_Success(self): + response = self.client.get( + reverse(self.get_url, args=[self.uuid]), + data={ + "enable": 0, + "baudrate": 9000, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 1, + }, + ) + self.assertEqual(200, response.status_code) + + @mock.patch("ag_data.models.AGEvent.objects.get", fake_event) + def test_Radio_Receiver_Fake_GET_Success(self): + response = self.client.get( + reverse(self.get_url, args=[self.uuid]), + data={ + "enable": 1, + "baudrate": 9000, + "bytesize": 8, + "parity": "N", + "stopbits": 1, + "timeout": 1, + "fake": 1, + }, + ) + self.assertEqual(200, response.status_code) diff --git a/mercury/urls.py b/mercury/urls.py index 492a1fd3..fb52b41a 100644 --- a/mercury/urls.py +++ b/mercury/urls.py @@ -4,7 +4,9 @@ sensor, events, pitcrew, + radioreceiver, gf_config, + measurement, ) app_name = "mercury" @@ -40,6 +42,11 @@ path("events/export//csv", events.export_event), path("events/export//json", events.export_event), path("pitcrew/", pitcrew.PitCrewView.as_view(), name="pitcrew"), + path( + "radioreceiver/", + radioreceiver.RadioReceiverView.as_view(), + name="radioreceiver", + ), path("gfconfig/", gf_config.GFConfigView.as_view(), name="gfconfig"), path( "gfconfig/delete/", gf_config.delete_config, name="gfconfig_delete" @@ -47,4 +54,9 @@ path( "gfconfig/update/", gf_config.update_config, name="gfconfig_update" ), + path( + "measurement/", + measurement.MeasurementView.as_view(), + name="measurement", + ), ] diff --git a/mercury/views/events.py b/mercury/views/events.py index ad533b72..fd0c13c1 100644 --- a/mercury/views/events.py +++ b/mercury/views/events.py @@ -77,10 +77,7 @@ def export_event(request, event_uuid=None, file_format="CSV"): } measurement_info.append(temp) - data = { - "event_info": event_info, - "measurement_info": measurement_info, - } + data = {"event_info": event_info, "measurement_info": measurement_info} response = HttpResponse(str(data), content_type="application/json") response["Content-Disposition"] = ( diff --git a/mercury/views/measurement.py b/mercury/views/measurement.py new file mode 100644 index 00000000..797d9c7a --- /dev/null +++ b/mercury/views/measurement.py @@ -0,0 +1,63 @@ +import json + +from rest_framework import status, serializers +from rest_framework.response import Response +from rest_framework.views import APIView + +from ag_data.models import AGEvent +from ag_data.serializers import AGMeasurementSerializer + + +def build_error(str): + return json.dumps({"error": str}) + + +class MeasurementView(APIView): + def post(self, request, event_uuid=None): + """ + The post receives sensor data through internet + Url example: + http://localhost:8000/radioreceiver/d81cac8d-26e1-4983-a942-1922e54a943d + Post Json Data Example + { + "sensor_id": 1, + "values": { + "power" : "1", + "speed" : "2", + } + "date" : 2020-03-11T20:20+01:00 + } + """ + # First check event_uuid exists + try: + event = AGEvent.objects.get(uuid=event_uuid) + except AGEvent.DoesNotExist: + event = False + if event is False: + return Response( + build_error("Event uuid not found"), status=status.HTTP_404_NOT_FOUND + ) + + json_data = request.data + if isinstance(json_data, str): + json_data = json.loads(json_data) + + res = {"event_uuid": event_uuid} + dic = {"timestamp": "date", "sensor_id": "sensor_id", "value": "values"} + + for d in dic: + if json_data.get(dic[d]) is None: + return Response( + build_error("Missing required params " + dic[d]), + status=status.HTTP_400_BAD_REQUEST, + ) + res[d] = json_data[dic[d]] + + serializer = AGMeasurementSerializer(data=res) + try: + serializer.is_valid(raise_exception=True) + serializer.save() + except serializers.ValidationError: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/mercury/views/radioreceiver.py b/mercury/views/radioreceiver.py new file mode 100644 index 00000000..9b79755e --- /dev/null +++ b/mercury/views/radioreceiver.py @@ -0,0 +1,177 @@ +import datetime +import glob +import json +import os +import sys + +import serial +from django.core.serializers.json import DjangoJSONEncoder +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from subprocess import Popen + +from ag_data.models import AGEvent + + +def serial_ports(): + """ Lists serial port names + + :raises EnvironmentError: + On unsupported or unknown platforms + :returns: + A list of the serial ports available on the system + """ + if sys.platform.startswith("win"): + ports = ["COM%s" % (i + 1) for i in range(256)] + elif sys.platform.startswith("darwin"): + ports = glob.glob("/dev/tty.*") + elif sys.platform.startswith("linux") or sys.platform.startswith("cygwin"): + # this excludes your current terminal "/dev/tty" + ports = glob.glob("/dev/tty[A-Za-z]*") + else: + raise EnvironmentError("Unsupported platform") + + result = [] + for port in ports: + try: + s = serial.Serial(port) + s.close() + result.append(port) + except (OSError, serial.SerialException): + pass + + if "TRAVIS" in os.environ: + result = ["dev/tty.USB"] + return result + + +def call_script(uuid, port, fake): + """ + Run a shell script to receive radio sensor data from the vehicle + This script will call local server to store all data received + Command Example: + python3 ./scripts/radioport.py --uuid d81cac8d-26e1-4983-a942-1922e54a943d + --port /dev/tty.SOC --fake + --data '{"sensor_id": 1, "values": {"power": "2", "speed": 1}, + "date": "2020-02-02T20:21:22"}' + """ + command = "python3 ./scripts/radioport.py --uuid {} --port {} ".format( + str(uuid), str(port) + ) + if fake: + command = command + "--fake --data " + data = { + "sensor_id": 1, + "values": {"power": "2", "speed": 1}, + "date": datetime.datetime(2020, 2, 2, 20, 21, 22), + } + data = json.dumps(data, cls=DjangoJSONEncoder) + command = command + "'" + data + "'" + print(command) + Popen(command, shell=True) + + +def check_port(ser): + """ + Package ser.is_open for test purpose + TODO: Add other checks on Serial port + """ + return ser.is_open + + +def build_error(str): + return json.dumps({"error": str}) + + +class RadioReceiverView(APIView): + """ + This is a Django REST API supporting user to send GET request fetching the RADIO + module configuration settings from backend and to send POST request sending + JSON-formatted data. + """ + + def get(self, request, event_uuid=None): + """ + The get request sent from web to determine the parameters of the serial port + Url Sample: + https://localhost:8000/radioreceiver/d81cac8d-26e1-4983-a942-1922e54a943d? + &enable=1&baudrate=8000&bytesize=8&parity=N&stopbits=1&timeout=None&fake=1 + uuid: event_uuid + enable: must define, set the port on if 1, off if 0 + baudrate: Optional, default 9600 + bytesize: Optional, default 8 bits + parity: Optional, default no parity + stop bits: Optional, default one stop bit + timeout: Optional, default 1 second + fake: Optional, send fake data for test only + """ + # First check event_uuid exists + try: + event = AGEvent.objects.get(uuid=event_uuid) + except AGEvent.DoesNotExist: + event = False + if event is False: + return Response( + build_error("Event uuid not found"), status=status.HTTP_404_NOT_FOUND + ) + + # Check Serial port parameters + params = request.query_params + enable = params.get("enable") + if enable is None: + return Response( + build_error("Missing enable value in url"), + status=status.HTTP_400_BAD_REQUEST, + ) + enable = int(enable) + ser = serial.Serial() + + valid_ports = serial_ports() + if len(valid_ports) > 0: + ser.port = valid_ports[0] + else: + return Response( + build_error("No valid ports on the backend"), + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + res = {"enable": enable} + + if params.get("baudrate"): + ser.baudrate = params.get("baudrate") + if params.get("bytesize"): + bytesize = int(params.get("bytesize")) + ser.bytesize = bytesize + if params.get("parity"): + ser.parity = params.get("parity") + if params.get("stopbits"): + ser.stopbits = int(params.get("stopbits")) + if params.get("timeout"): + timeout = int(params.get("timeout")) + ser.timeout = timeout + + fake = params.get("fake") + if fake: + call_script(event_uuid, ser.port, fake) + elif enable: + try: + ser.open() + if check_port(ser): + # Call Script + call_script(event_uuid, ser.port, fake) + except serial.serialutil.SerialException: + pass + else: + if check_port(ser): + ser.close() + print("listening port closed") + + # Response data + res["baudrate"] = ser.baudrate + res["bytesize"] = ser.bytesize + res["parity"] = ser.parity + res["stopbits"] = ser.stopbits + res["timeout"] = ser.timeout + + return Response(json.dumps(res), status=status.HTTP_200_OK) diff --git a/mercury/views/sensor.py b/mercury/views/sensor.py index 7ce62cec..ab416d54 100644 --- a/mercury/views/sensor.py +++ b/mercury/views/sensor.py @@ -224,10 +224,7 @@ def post(self, request, *args, **kwargs): ) new_type.save() sensor_types = AGSensorType.objects.all() - context = { - "sensor_types": sensor_types, - "sensors": sensors, - } + context = {"sensor_types": sensor_types, "sensors": sensors} else: sensor_types = AGSensorType.objects.all() context = { @@ -291,10 +288,7 @@ def post(self, request, *args, **kwargs): ) sensors = AGSensor.objects.all() - context = { - "sensors": sensors, - "sensor_types": sensor_types, - } + context = {"sensors": sensors, "sensor_types": sensor_types} else: sensors = AGSensor.objects.all() context = { diff --git a/requirements.txt b/requirements.txt index 5dfdbb24..8b68cd96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ django-import-export drf-yasg selenium==3.141.0 django-annoying -djangorestframework +mock +requests \ No newline at end of file diff --git a/scripts/radioport.py b/scripts/radioport.py new file mode 100644 index 00000000..d1d7bccb --- /dev/null +++ b/scripts/radioport.py @@ -0,0 +1,86 @@ +import argparse +import datetime +import json +import os +import time + +import requests +import serial + +from django.core.serializers.json import DjangoJSONEncoder + + +class RadioPort: + def __init__(self, event_id, serial_port): + self.event_id = event_id + self.serial_port = serial_port + self.url = "http://127.0.0.1:8000/measurement/" + + def post_request(self, data): + headers = {"Content-type": "application/json"} + + r = requests.post(self.url + str(self.event_id), json=data, headers=headers) + + print("Status: " + str(r.status_code)) + print("Body: " + str(r.content)) + + def post_fake_request(self, data): + if len(data) == 0: + data = { + "sensor_id": 1, + "values": {"power": "2", "speed": 1}, + "date": datetime.datetime(2020, 2, 2, 20, 21, 22), + } + data = json.dumps(data, cls=DjangoJSONEncoder) + + # Skip in travis since there is no database in travis + if "TRAVIS" in os.environ: + return + r = requests.post(self.url + str(self.event_id), json=data) + + print("Status: " + str(r.status_code)) + print("Body: " + str(r.content)) + + def listen_port(self): + print("Start listening port") + while self.serial_port.is_open: + data = self.serial_port.readline() + self.post_request(data=data) + time.sleep(1) + + +if __name__ == "__main__": + print("Call radioport.py script") + parser = argparse.ArgumentParser(description="Radio port setter.") + parser.add_argument("-u", "--uuid", required=True, help="Event_uuid for AGEvent") + parser.add_argument("-p", "--port", help="Port name for serial") + parser.add_argument( + "-d", "--data", type=json.loads, help="Data to send, must be json string" + ) + parser.add_argument( + "-f", + "--fake", + default=False, + type=bool, + const=True, + nargs="?", + help="send the fake post request", + ) + + args = parser.parse_args() + + if args.fake: + radio_port = RadioPort(args.uuid, None) + print("Send fake data") + print(args.data) + radio_port.post_fake_request(args.data) + else: + try: + ser = serial.Serial(args.port) + radio_port = RadioPort(args.uuid, ser) + if ser.is_open: + radio_port.listen_port() + else: + print("Serial is invalid") + except serial.serialutil.SerialException: + print("Serial is invalid")