From 81d92887f696d326de9ab1d06c3d92f8b4ca84f2 Mon Sep 17 00:00:00 2001 From: "michael.saunders" Date: Fri, 6 Nov 2020 12:44:14 +0400 Subject: [PATCH] Initial commit --- README | 104 ++++++++++++++++++++++ qgc2dji.py | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 README create mode 100644 qgc2dji.py diff --git a/README b/README new file mode 100644 index 0000000..31508ae --- /dev/null +++ b/README @@ -0,0 +1,104 @@ +## Description + +Convert QGroundControl survey waypoints to DJI GO 4 waypoints 2.0. +This is spefically intended to work _only_ with the "survey" mode from +QGroundControl. + +This will likely only work if your smartphone is rooted, as you will need `su`. +My personal recommendation is to get `Magisk` installed. + +This script is largely very naiive. It works as expected with `qgroundcontrol +v4.0.11-1` and `DJI GO 4 v??` as of date 06-11-2020. + +There is no guarantee it will continue to work with future updates of either +application. You have been warned. + +By default, a backup of the original DJI GO 4 database is made, so even if +the resulting db is broken, you can always recover the original. + +Has only been tested using `omnirom beryllium` - your mileage may vary. + + +## Dependencies + +For the script: +- Python 3 + - all required modules are I believe part of python core +- sqlite3 + +Obviously: +- QGroundControl +- DJI GO 4 + + +## Prerequisite + +- Open QGroundControl + - either with the app on your phone, or more easily with the +desktop version. Tested with Linux and the App, but should be the same for Mac +and Windoze +- Create a survey waypoint mission +- Make sure the camera lens settings are all correct +- Use your common sense to determine + - suitable overlap + - cruising flight speed + - suitable altitude + - other? + +Some settings can be passed directly to the script from the commandline, and +will overwrite the equivalent values from QGroundControl. + +*n.b.* This script currently ignores takeoff and landing points, and solely focusses +on the survey mission waypoints, at a single altitude. + + +## Preparation + +It is assumed that you have a terminal open on your "host" in a suitable working +directory, and that `qgc2dji.py` is in that directory. +When copying the `.db` you can store it wherever you like - it just has to be a +location that `adb pull` can retrieve from. +Once your phone is rooted and reasonably under your own control (developer +options enabled, USB debugging enabled): + + +## Usage + +Use your own judgement when it comes to `chown` and `chmod` to match the original +`.db` + +``` +[host]$ adb shell + +[phone]$ su +[phone]# cp /data/data/dji.go.v4/databases/way_point_2.db /sdcard/ +[phone]# +[phone]$ + +[host]$ adb pull /sdcard/way_point_2.db ./ +[host]$ python ./qgc2dji.py -i -d ./way_point_2.db +[host]$ adb push ./way_point_2.db /sdcard/ +[host]$ adb shell + +[phone]$ su +[phone]# cd /data/data/dji.go.v4/databases +[phone]# mv /sdcard/way_point_2.db ./ +[phone]# ls -ld +drwxrwx--x 2 4096 2020-11-06 11:48 . + +[phone]# chown : ./way_point_2.db +[phone]# chmod 660 ./way_point_2.db +``` + + +## Future + +- Also update 'way_point_2_cache.db' +- Choice of "action upon mission completion" + - Currently defaults to hover +- Altitude per waypoint +- Takeoff and landing points? +- Calculate appropriate crusing flight speed? + - Dependent on required overlap and limitation of camera regarding photo + interval +- Waypoint triggers (start/stop recording, take photo, etc) diff --git a/qgc2dji.py b/qgc2dji.py new file mode 100644 index 0000000..36479e5 --- /dev/null +++ b/qgc2dji.py @@ -0,0 +1,255 @@ +#!/usr/bin/python3 + +# Convert QGroundControl survey waypoints to DJI GO 4 waypoints 2.0 +# +# Solely for the creation of terrain survey mapping +# at a fixed altitude. + +import sys +import os +import shutil +import argparse +import time +import math +import random +import sqlite3 +import json + + +####### Globals ################################################################ + +mission_table = 'dji_pilot_dji_groundstation_waypoint2_model_WayPoint2MissionDBModel' +waypoint_table = 'dji_pilot_dji_groundstation_waypoint2_model_WayPoint2MissionDBModel$WayPoint2DBPoint' + + +####### Helper Function(s) ##################################################### + +def haversine(coord1, coord2): + R = 6371000 #6372800 # Earth radius in meters [looks like DJI GO 4 uses 6371000] + lat1, lon1 = coord1 + lat2, lon2 = coord2 + + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + + a = math.sin(dphi/2)**2 + \ + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2 + + return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +################################################################################ + + +# TODO: Confirm that 'complexItemType' = "survey" +# TODO: Also update 'way_point_2_cache.db' +# - delete existing cached mission, and its' waypoints +# - insert the same new data as with 'way_point_2.db' + +parser = argparse.ArgumentParser(description='Convert QGroundControl survey waypoints to DJI GO 4 waypoints 2.0') + +parser.add_argument('-d', '--dbfile', required=True, type=str, + help='DJI GO 4 database file') +parser.add_argument('-i', '--infile', required=True, type=argparse.FileType('r'), + help='JSON input file') +parser.add_argument('-a', '--altitude', type=float, + help='Altitude above ground, in meters') +parser.add_argument('-f', '--finishaction', type=int, default=4, + help='Act to perform upon mission completion [0, 1, 2, 3 or 4 - hover] (default: %(default)s)') +parser.add_argument('-n', '--name', type=str, + help='Name for the mission (qgc2dji-survey_)') +parser.add_argument('-s', '--speed', type=float, + help='Flight speed') + +args = parser.parse_args() + +if not os.path.isfile(args.dbfile): + print("[ERROR] Database file does not exist!", file=sys.stderr) + exit(2) + +try: + conn = sqlite3.connect(args.dbfile) +except sqlite3.Error as e: + print("[ERROR] Could not connect to database!\n" + e.args[0], file=sys.stderr) + exit(2) + + +# The only values we care about are inside "TransectStyleComlexItem" +items = json.loads(args.infile.read())['mission']['items'] + +main_data = None +for item in items: + if 'TransectStyleComplexItem' in item: + main_data = item["TransectStyleComplexItem"] + break +if not main_data: + print("[ERROR] Required data not present in json!", file=sys.stderr) + exit(2) + +altitude = args.altitude +if not altitude: + altitude = main_data["CameraCalc"]["DistanceToSurface"] + +finishaction = args.finishaction + +# If this is no set, it will be later on during parsing +flight_speed = args.speed +if not flight_speed: + # First check if a value was set in the json + for item in items: + if 'command' in item and item['command'] == 178: + # Reverse-engineered this location - could easily change in future QGroundControl releases + flight_speed = item['params'][1] + break +# Set an acceptable, but slow default if speed still hasn't been set +# TODO: Calculate this based on mission distance / photo count / 5s to get one photo per 5seconds ??? +if not flight_speed: + flight_speed = 3.5 + +mission_name = args.name +if not mission_name: + timestr = time.strftime('%d%m%Y-%H%M%S') + mission_name = "qgc2dji-survey_" + timestr + +# DJI GO database stores time in milliseconds since epoch +update_time = int(round(time.time() * 1000)) + + +cur = conn.cursor() + +cur.execute(f'SELECT missionId FROM {mission_table}') +existing_ids = cur.fetchall() + +# Looking at existing database entries, it appears that 'missionId' is an arbitrary value +mission_id = random.randrange(10000,99999) +while (mission_id,) in existing_ids: + print("[INFO] ID collision. Generating new ID.") + mission_id = random.randrange(10000,99999) + +waypoint_queue = [] + +# Set default values for DJI GO database waypoint table +heading_type = 0 # Free ? +poi_index = -1 # None ? +heading = 0 # Look-ahead ? +action = 0 # Do nothing (as opposed to take photo, etc) +pitch = -90.0 # Point camera toward ground +radius = 2.0 # Some default ? Could be realted to 'arc' vs 'polyline' +speed = 0.0 # Use 'autoFlightSpeed' from 'mission_table' + +# Prepare each waypoint for insert +waypoints = main_data['VisualTransectPoints'] +last_coord = () +curr_coord = () +accum_distance = 0 + +# TEST DATA - Expected accumulated result: 1825.33557128906 +# Result initially was 1825.8512451494107 which should be close enough +# Result with "standard" Earth radius is 1825.3355327088402 which is closer still +# TODO: Test geopy module ? [see: https://janakiev.com/blog/gps-points-distance-python/] +#waypoints = [(-19.9914615912953,57.6010317688705), +# (-19.9906124706645,57.6018561685789), +# (-19.9907586941667,57.6021400677547), +# (-19.9917566018806,57.6012092059048), +# (-19.9920208284113,57.6013183979067), +# (-19.9908972216132,57.6023884794603), +# (-19.9910049650248,57.6026669190904), +# (-19.9923184032966,57.601438509184), +# (-19.9925544106552,57.6017169485915), +# (-19.9911050125569,57.6029944951315), +# (-19.9912281477626,57.6033111519851), +# (-19.9927904174512,57.6019353328111), +# (-19.9931793793255,57.6022154786425), +# (-19.9915212340617,57.6036721688515), +# (-19.9915956280736,57.6039779066684), +# (-19.9934298150556,57.6024819755698)] + +count = 0 +for waypoint in waypoints: + latitude = waypoint[0] + longitude = waypoint[1] + waypoint_id = count + count = count + 1 + + waypoint_queue.append((heading_type, altitude, mission_id, poi_index, heading, latitude, action, pitch, radius, speed, longitude, waypoint_id)) + + if count > 1: + curr_coord = (latitude, longitude) + accum_distance = accum_distance + haversine(last_coord, curr_coord) + last_coord = curr_coord + else: + last_coord = (latitude, longitude) + + +# Set default values for DJI GO database mission table +is_use_custom_direction = 0 +first_lon = 0.0 +first_lat = 0.0 +local = ''#None # becomes NULL in sqlite db +exit_mission_on_rc_lost = 0 # Keep flying +flight_path_mode = 0 # 'polyline' vs 'arc' ? +is_cache = 0 # ? +rotate_gimbal_pitch = 1 # ? +goto_first_waypoint_mode = 0 # ? +repeat_times = 1 # Only fly the mission once +max_flight_speed = 8.3 # (m/s) Based on ~30km/h being a good maximum +heading_mode = 0 # ? Different from 'heading_type' ? +is_enable_multi_poi = 0 +route_distance = accum_distance #'' #None # Can we get away with leaving this blank !? +point_count = len(waypoints) + + +print("\n======= MISSION DATA =======\n") +print(f"Mission name:\t\t{mission_name}") +print(f"Mission id:\t\t{mission_id}") +print(f"Max flight speed:\t{max_flight_speed} m/s") +print(f"Cruise speed:\t\t{flight_speed} m/s") +print(f"Altitude:\t\t{altitude} m") +print(f"Route distance:\t\t{route_distance} m") +print(f"Finished action:\t{finishaction}") +print("") + +# TODO: Implement more robust regex version of the following +proceed = '' +while proceed not in ['y', 'Y', 'n', 'N']: + proceed = input("Write out to database? [y/n] ") + +if proceed == 'n' or proceed == 'N': + print("[INFO] Exiting without updating database.") + exit(1) + + +# Generate SQL INSERT for the mission +sql = (f"INSERT INTO {mission_table}" + "(missionId, isUseCustomDirection, updateTime, finishedAction, firstLng, local, " + "exitMissionOnRCSignalLost, flightPathMode, isCache, rotateGimbalPitch, " + "gotoFirstWaypointMode, pointCount, repeatTimes, routDistance, firstLat, " # n.b. 'routDistance' is not a spelling mistake + "missionName, maxFlightSpeed, headingMode, autoFlightSpeed, isEnableMultiPOI) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") +cur.execute(sql, (mission_id, is_use_custom_direction, update_time, finishaction, + first_lon, local, exit_mission_on_rc_lost, flight_path_mode, + is_cache, rotate_gimbal_pitch, goto_first_waypoint_mode, point_count, + repeat_times, route_distance, first_lat, mission_name, max_flight_speed, + heading_mode, flight_speed, is_enable_multi_poi)) + +# SQL INSERT for all waypoints from QGroundControl +sql = (f"INSERT INTO {waypoint_table}" + "(headingType, altitude, missionId, poiIndex, heading, latitude, action, " + "pitch, radius, speed, longitude, myIndex) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)") +cur.executemany(sql, waypoint_queue) + + +# TODO: Implement database backup as a commandline option +shutil.copy2(args.dbfile, args.dbfile + ".bak") + + +# Write changes back to the database +conn.commit() + + +# TODO: Determine if there is any other clean up + + +conn.close()