diff --git a/server/server_comm/device_connection/apps.py b/server/server_comm/device_connection/apps.py index 990ea51..b0af077 100644 --- a/server/server_comm/device_connection/apps.py +++ b/server/server_comm/device_connection/apps.py @@ -2,5 +2,5 @@ class DeviceConnectionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'device_connection' + default_auto_field = "django.db.models.BigAutoField" + name = "device_connection" diff --git a/server/server_comm/device_connection/urls.py b/server/server_comm/device_connection/urls.py index dd1122f..fe40fd1 100644 --- a/server/server_comm/device_connection/urls.py +++ b/server/server_comm/device_connection/urls.py @@ -3,6 +3,7 @@ from .views import ( AndroidDeviceConnectionView, APIConnectionView, + FlowDeviceConnectionView, SerialDeviceConnectionView, ) @@ -18,4 +19,9 @@ name="android-connection", ), path("api/", APIConnectionView.as_view(), name="api-connection"), + path( + "connect_flow/", + FlowDeviceConnectionView.as_view(), + name="flow-connection", + ), ] diff --git a/server/server_comm/device_connection/views.py b/server/server_comm/device_connection/views.py index 00c5b6a..e8981c4 100644 --- a/server/server_comm/device_connection/views.py +++ b/server/server_comm/device_connection/views.py @@ -23,53 +23,53 @@ def check_connection(self, conn_id: str): device_port = self.get_device_port(conn_id) serial_device = Serial(device_port, timeout=10) - serial_device.write(b'ping\n') + serial_device.write(b"ping\n") time.sleep(1) res = serial_device.readline().strip() if res: return JsonResponse( { - 'status': 'connected', - 'message': 'serial device connected', - 'response': res, + "status": "connected", + "message": "serial device connected", + "response": res, } ) else: return JsonResponse( { - 'status': 'no_response', - 'message': 'serial device did not respond', - 'response': None, + "status": "no_response", + "message": "serial device did not respond", + "response": None, } ) except SerialTimeoutException as e: return JsonResponse( { - 'status': 'error', - 'message': 'serial device timed out', - 'response': str(e), + "status": "error", + "message": "serial device timed out", + "response": str(e), } ) except Exception as e: return JsonResponse( { - 'status': 'error', - 'message': 'an error occurred', - 'response': str(e), + "status": "error", + "message": "an error occurred", + "response": str(e), } ) def get(self, request): - conn_id = request.GET.get('conn_id') + conn_id = request.GET.get("conn_id") if conn_id: return self.check_connection(conn_id) else: return JsonResponse( { - 'status': 'error', - 'message': 'conn_id is required', - 'response': None, + "status": "error", + "message": "conn_id is required", + "response": None, } ) @@ -78,7 +78,7 @@ class AndroidDeviceConnectionView(View): def get_adb_devices(self): try: adb_devices = subprocess.run( - ['adb', 'devices'], capture_output=True, text=True, check=True + ["adb", "devices"], capture_output=True, text=True, check=True ).stdout.splitlines()[1:-1] adb_devices = [line.split("\t")[0] for line in adb_devices] return adb_devices @@ -86,27 +86,27 @@ def get_adb_devices(self): except subprocess.CalledProcessError as e: return JsonResponse( { - 'status': 'error', - 'message': 'adb command failed', - 'response': str(e), + "status": "error", + "message": "adb command failed", + "response": str(e), } ) except FileNotFoundError as e: return JsonResponse( { - 'status': 'error', - 'message': 'adb not found', - 'response': str(e), + "status": "error", + "message": "adb not found", + "response": str(e), } ) except Exception as e: return JsonResponse( { - 'status': 'error', - 'message': 'an error occurred', - 'response': str(e), + "status": "error", + "message": "an error occurred", + "response": str(e), } ) @@ -116,30 +116,30 @@ def check_connection(self, conn_id: str): if conn_id in adb_devices: return JsonResponse( { - 'status': 'connected', - 'message': 'android device connected', - 'response': None, + "status": "connected", + "message": "android device connected", + "response": None, } ) else: return JsonResponse( { - 'status': 'not_connected', - 'message': 'android device not connected', - 'response': None, + "status": "not_connected", + "message": "android device not connected", + "response": None, } ) def get(self, request): - conn_id = request.GET.get('conn_id') + conn_id = request.GET.get("conn_id") if conn_id: return self.check_connection(conn_id) else: return JsonResponse( { - 'status': 'error', - 'message': 'conn_id is required', - 'response': None, + "status": "error", + "message": "conn_id is required", + "response": None, } ) @@ -152,56 +152,56 @@ def check_connection(self, api_url: str): if res.status_code == 200: return JsonResponse( { - 'status': 'connected', - 'message': 'connected to api', - 'response': res.json(), + "status": "connected", + "message": "connected to api", + "response": res.json(), } ) else: JsonResponse( { - 'status': 'error', - 'message': ( - f'unexpected api response {res.status_code}' + "status": "error", + "message": ( + f"unexpected api response {res.status_code}" ), - 'response': res.json(), + "response": res.json(), } ) except requests.exceptions.Timeout as e: return JsonResponse( { - 'status': 'error', - 'message': 'api request timed out', - 'response': str(e), + "status": "error", + "message": "api request timed out", + "response": str(e), } ) except requests.exceptions.ConnectionError as e: return JsonResponse( { - 'status': 'error', - 'message': 'could not connect to api', - 'response': str(e), + "status": "error", + "message": "could not connect to api", + "response": str(e), } ) except Exception as e: return JsonResponse( { - 'status': 'error', - 'message': 'an error occurred', - 'response': str(e), + "status": "error", + "message": "an error occurred", + "response": str(e), } ) def get(self, request): - api_url = request.GET.get('api_url') + api_url = request.GET.get("api_url") if api_url: return self.check_connection(api_url) else: return JsonResponse( { - 'status': 'error', - 'message': 'api_url is required', - 'response': None, + "status": "error", + "message": "api_url is required", + "response": None, } ) @@ -242,14 +242,14 @@ def connect_devices(self, flow_id: str): android_view.check_connection(conn_id) def get(self, request): - flow_id = request.GET.get('flow_id') + flow_id = request.GET.get("flow_id") if flow_id: return self.connect_devices(flow_id) else: return JsonResponse( { - 'status': 'error', - 'message': 'flow_id is required', - 'response': None, + "status": "error", + "message": "flow_id is required", + "response": None, } ) diff --git a/server/server_comm/server_comm/settings.py b/server/server_comm/server_comm/settings.py index e8ec7a5..2882ebe 100644 --- a/server/server_comm/server_comm/settings.py +++ b/server/server_comm/server_comm/settings.py @@ -82,7 +82,7 @@ WSGI_APPLICATION = "server_comm.wsgi.application" CORS_ALLOWED_ORIGINS = [ - 'http://localhost:5173', + "http://localhost:5173", ] # Database diff --git a/server/server_comm/test_runner/apps.py b/server/server_comm/test_runner/apps.py index f003ce4..c14dc5f 100644 --- a/server/server_comm/test_runner/apps.py +++ b/server/server_comm/test_runner/apps.py @@ -2,5 +2,5 @@ class TestRunnerConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'test_runner' + default_auto_field = "django.db.models.BigAutoField" + name = "test_runner" diff --git a/server/server_comm/test_runner/nrf_scripts/check_characteristic.xml b/server/server_comm/test_runner/nrf_scripts/check_characteristic.xml new file mode 100644 index 0000000..8983715 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/check_characteristic.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/server_comm/test_runner/nrf_scripts/check_service.xml b/server/server_comm/test_runner/nrf_scripts/check_service.xml new file mode 100644 index 0000000..758a0a1 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/check_service.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/server_comm/test_runner/nrf_scripts/connection.xml b/server/server_comm/test_runner/nrf_scripts/connection.xml new file mode 100644 index 0000000..d6f43a0 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/connection.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/server_comm/test_runner/nrf_scripts/custom_script.xml b/server/server_comm/test_runner/nrf_scripts/custom_script.xml new file mode 100644 index 0000000..af67758 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/custom_script.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/server_comm/test_runner/nrf_scripts/nrf_connect.py b/server/server_comm/test_runner/nrf_scripts/nrf_connect.py new file mode 100644 index 0000000..42037c5 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/nrf_connect.py @@ -0,0 +1,155 @@ +import os +import subprocess + + +def run_command(name, command, filename): + try: + result = subprocess.run(command, capture_output=True, text=True) + stdout, stderr = result.stdout, result.stderr + except Exception as e: + return { + "status": "error", + "message": f"Exception occurred while executing command: {name}", + "response": str(e), + } + + if stderr: + return { + "status": "error", + "message": f"Error occurred while running command: {name}", + "response": stderr, + } + + if "Error" in stdout: + return { + "status": "error", + "message": f"Error occurred while running command: {name}", + "response": stdout, + } + + with open(filename, "r") as file: + lines = file.readlines() + + if "completed" in lines[-1]: + return { + "status": "success", + "message": f"Command: {name}, ran successfully", + "response": "", + } + + for i in range(len(lines)): + if name in lines[i]: + start_idx = i + break + + error_lines = [] + for line in lines[start_idx:]: + line = line.split("\t") + if line[0] == "E" or line[0] == "W": + error_lines.append(line) + + return { + "status": "error", + "message": f"nRF script: {name}, failed", + "response": error_lines, + } + + +def run_check_connection( + android_device_id, + mac_address, +): + script_dir = os.path.dirname(__file__) + os.chdir(script_dir) + + command = [ + "./test.sh", + "-d", + android_device_id, + "-e", + "MAC_ADDRESS", + mac_address, + "connection.xml", + ] + + return run_command("Connect", command, "connection_result.txt") + + +def run_check_service( + android_device_id, + mac_address, + service_uuid, +): + script_dir = os.path.dirname(__file__) + os.chdir(script_dir) + + command = [ + "./test.sh", + "-d", + android_device_id, + "-e", + "MAC_ADDRESS", + mac_address, + "-e", + "SERVICE_UUID", + service_uuid, + "check_service.xml", + ] + + return run_command( + "Check Service UUID", command, "check_service_result.txt" + ) + + +def run_check_characteristic( + android_device_id, + mac_address, + service_uuid, + characteristic_uuid, +): + script_dir = os.path.dirname(__file__) + os.chdir(script_dir) + + command = [ + "./test.sh", + "-d", + android_device_id, + "-e", + "MAC_ADDRESS", + mac_address, + "-e", + "SERVICE_UUID", + service_uuid, + "-e", + "CHARACTERISTIC_UUID", + characteristic_uuid, + "check_characteristic.xml", + ] + + return run_command( + "Check Characteristic UUID", command, "check_characteristic_result.txt" + ) + + +def run_custom_script(android_device_id, mac_address, extras): + script_dir = os.path.dirname(__file__) + os.chdir(script_dir) + + extra_cmd = [] + for var, value in extras.items(): + extra_cmd.append("-e") + extra_cmd.append(var) + extra_cmd.append(value) + + command = [ + "./test.sh", + "-d", + android_device_id, + "-e", + "MAC_ADDRESS", + mac_address, + ] + command.extend(extra_cmd) + command.append("custom_script.xml") + + return run_command("Custom script", command, "custom_script_result.txt") diff --git a/server/server_comm/test_runner/nrf_scripts/test.sh b/server/server_comm/test_runner/nrf_scripts/test.sh new file mode 100755 index 0000000..96885e3 --- /dev/null +++ b/server/server_comm/test_runner/nrf_scripts/test.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Copyright (c) 2024, Nordic Semiconductor +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of nRF Toolbox nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Description: +# ------------ +# The script allows to run automated tests using Android phone. +# The script may be run on Unix / Mac / Linux. +# +# Requirements: +# ------------- +# 1. Android device with Android version 4.3+ connected by USB cable with the PC +# 2. The path to Android platform-tools directory must be added to %PATH% environment variable +# 3. nRF Connect (2.1.0+) application installed on the Android device +# 4. "Developer options" and "USB debugging" must be enabled on the Android device +# +# Usage: +# ------ +# 1. Open console +# 2. Type "./test.sh --help" and press ENTER +# +# Android Debug Bridge (adb): +# --------------------------- +# You must have Android platform tools installed on the computer. +# Go to http://developer.android.com/sdk/index.html for more information how to install it on the computer. +# You do not need the whole ADT Bundle (with Eclipse or Android Studio). Only SDK Tools with Platform Tools are required. + +# Set variables +PROGRAM="$0" +DEVICE="" +S_DEVICE="" +EXTRAS="" + +# Check ADB +if ! command -v adb > /dev/null 2>&1; then + echo "Error: adb is not recognized as an external command." + echo " Add [Android Bundle path]/sdk/platform-tools to \$PATH" + exit 1 +fi + +# Check help +if [ -z "$1" ] || [ "$1" = "--help" ]; then + echo "Usage: $PROGRAM [-D device_id] [-E key value] script.xml" + echo "Info:" + echo "device_id - Call: \"adb devices\" to get a list of serial numbers" + echo "key value - You may pass 0+ parameters to the Test Service, e.g.," + echo " -E EXTRA_ADDRESS \"AA:BB:CC:DD:EE:FF\" -E SOMETHING \"important\"" + echo " and use them in the script.xml file as e.g.:" + echo " ..address=\"\${EXTRA_ADDRESS}\"..." + exit 1 +fi + +# Read target device id +if [ "$1" = "-d" ]; then + TARGET_DEVICE_SET=1 + shift + DEVICE="$1" + S_DEVICE="-s $1" + shift +fi + +# Read optional extra parameters +while [ "$1" = "-e" ]; do + EXTRAS_SET=1 + shift + EXTRAS="$EXTRAS -e $1 \"$2\"" + shift 2 +done + +# Write intro +echo "=================================================" +echo "Nordic Semiconductor Automated Tests shell script" +echo "=================================================" + +# Read file name and fully qualified path name to the XML file +if [ -z "$1" ]; then + echo "Error: Test script file name not specified." + exit 1 +fi + +XML_FILE=$(basename "$1") +RESULT_FILE="${1%.*}_result.txt" +XML_PATH=$(realpath "$1") + +if [ ! -e "$XML_PATH" ]; then + echo "Error: The given test script file has not been found." + exit 1 +fi + +# Check if there is only one device connected +if [ -z "$DEVICE" ]; then + DEVICE_COUNT=$(adb devices | grep -v "devices" | grep -c "device\|unauthorized\|emulator") + if [ "$DEVICE_COUNT" -eq 0 ]; then + echo "Error: No device connected." + exit 1 + elif [ "$DEVICE_COUNT" -gt 1 ]; then + echo "Error: More than one device connected." + echo " Specify the device serial number using -D option:" + adb devices + exit 1 + fi +else + # Check if specified device is connected + if ! adb devices | grep -q "$DEVICE"; then + echo "Error: Device with serial number \"$DEVICE\" is not connected." + adb devices + exit 1 + fi +fi + +# Remove old result file (if exists) +echo -n "Removing old result file..." +adb $S_DEVICE shell rm "/sdcard/Android/data/no.nordicsemi.android.mcp/files/Test/$RESULT_FILE" > /dev/null 2>&1 +echo "OK" + +# Copy selected file onto the device +echo -n "Copying \"$XML_FILE\" to /sdcard/Android/data/no.nordicsemi.android.mcp/files/Test..." +adb $S_DEVICE push "$XML_PATH" "/sdcard/Android/data/no.nordicsemi.android.mcp/files/Test/$XML_FILE" > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "FAIL" + echo "Error: Device not found." + exit 1 +else + echo "OK" +fi + +# Start test service on the device +echo -n "Starting test service..." +adb $S_DEVICE shell am start-foreground-service -a no.nordicsemi.android.action.START_TEST $EXTRAS -e no.nordicsemi.android.test.extra.EXTRA_FILE_PATH "/sdcard/Android/data/no.nordicsemi.android.mcp/files/Test/$XML_FILE" > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "FAIL" + echo "Error: Required application not installed." + exit 1 +else + echo "OK" + echo "Test started...OK" +fi + +# Try to obtain the result. Wait 10 seconds before every try. +echo -n "Waiting for the result..." +while true; do + # Wait 10 sec, this IP address is reserved and does not exist + sleep 10 + adb $S_DEVICE pull "/sdcard/Android/data/no.nordicsemi.android.mcp/files/Test/$RESULT_FILE" "$RESULT_FILE" > /dev/null 2>&1 + if [ $? -eq 0 ]; then + break + fi +done +echo "OK" +echo "Result saved in \"$RESULT_FILE\"." +exit 0 \ No newline at end of file diff --git a/server/server_comm/test_runner/urls.py b/server/server_comm/test_runner/urls.py index 403b2ac..0d1483f 100644 --- a/server/server_comm/test_runner/urls.py +++ b/server/server_comm/test_runner/urls.py @@ -3,6 +3,6 @@ from .views import RunTestFlow urlpatterns = [ - path('run//', RunTestFlow.as_view(), name='run-test-flow'), - path('run/', RunTestFlow.as_view(), name='run-all-test-flows'), + path("run//", RunTestFlow.as_view(), name="run-test-flow"), + path("run/", RunTestFlow.as_view(), name="run-all-test-flows"), ] diff --git a/server/server_comm/test_runner/views.py b/server/server_comm/test_runner/views.py index a87afe9..e79c0b1 100644 --- a/server/server_comm/test_runner/views.py +++ b/server/server_comm/test_runner/views.py @@ -8,6 +8,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from test_runner.nrf_scripts.nrf_connect import run_check_connection class RunTestFlow(APIView): @@ -65,7 +66,7 @@ def check_device_connections(self, flow_id): protocol = 'https' if self.request.is_secure() else 'http' base_url = f"{protocol}://{self.request.get_host()}" check_devices_url = f""" - {base_url}{reverse('flow-device-connection')} + {base_url}{reverse('flow-connection')} ?{urlencode({'flow_id': flow_id})} """ response = requests.get(check_devices_url) @@ -91,16 +92,67 @@ def check_device_connections(self, flow_id): "message": "Failed to check device connections", } + def get_nrf_devices(self, devices): + """ + Return devices with mac_address field, aka devices to nrf connect + """ + nrf_devices = [] + + for device in devices: + if "mac_address" in device.communication_ids.keys(): + nrf_devices.append(device) + + return nrf_devices + + def connect_ble_nrf_devices(self, flow_id): + """ + Connect select device(s) in command nodes to nrf kit + """ + # TODO: does the user need to make category "Android" for this to work + # what if they wanna categorize differently? + # not all BLE/android devices should be connected, no? who should be? + # TODO: how do we check UUIDs? need to know if service/characteristic + + protocol = 'https' if self.request.is_secure() else 'http' + base_url = f"{protocol}://{self.request.get_host()}" + get_devices_url = f""" + {base_url}{reverse('devices')} + """ + response = requests.get(get_devices_url) + + if response.status_code == 200: + devices = response.json() + else: + return { + "status": "error", + "message": "Failed to get devices", + "response": response, + } + + nrf_devices = self.get_nrf_devices(devices) + + for device in nrf_devices: + dev_id = device.device_id + mac = device.communication_ids.get("mac_address") + res = run_check_connection(dev_id, mac) + + if res.get("status") == "error": + raise RuntimeError( + f"{res.get('message')}\n{res.get('response')}" + ) + return { + "status": "success", + "message": "Connected all nRF devices", + "response": "", + } + def test_setup(self, flow_id): """ Set up and assert device connections for the test flow. """ - # TODO comment back in when this API is fixed - # self.check_device_connections(flow_id) + self.check_device_connections(flow_id) - # TODO Connect android device(s) in command nodes to nrf kit (LIL-90) - - # TODO Assert that connection is setup (LIL-90) + self.connect_ble_nrf_devices(flow_id) def run_node(self, node): """ @@ -112,8 +164,13 @@ def run_node(self, node): # TODO add code from LIL-91 result = True elif node.node_type == Node.ACTION: - # TODO add code from LIL-95 - result = True + try: + # TODO: HOW DO WE RUN NRF SCRIPTS? + exec(node.function) + result = True + except Exception as e: + ex_type = type(e).__name__ + raise ValueError(f"Invalid Python code, {ex_type}:{e}") else: raise ValueError(f"Invalid node type: {node.node_type}") return {