├── go.mod ├── .gitattributes ├── fp2lm ├── testdata │ ├── FlightplannerMission.csv │ ├── examples │ │ ├── TestOutput.csv │ │ ├── FinalTest.csv │ │ ├── TestFixed.csv │ │ ├── TestFixedASL.csv │ │ ├── LitchiMissionTest.csv │ │ └── LitchiMissionTest2.csv │ ├── new_golden.csv │ └── litchi_golden.csv ├── README.md ├── fp2lm_test.go └── fp2lm.go ├── .goreleaser.yaml ├── .github └── workflows │ ├── debug-release.yml │ ├── simple-build.yml │ ├── direct-build.yml │ ├── release.yml │ └── ci.yml ├── cmd └── polyorbit │ └── main.go ├── LICENSE ├── polyorbit └── polyorbit.go ├── Makefile ├── lenconv ├── README.md └── lenconv.go ├── missioncsv ├── README.md └── writer.go ├── .gitignore └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module flightplan2litchimission 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /fp2lm/testdata/FlightplannerMission.csv: -------------------------------------------------------------------------------- 1 | Waypoint Number,X [m],Y [m],Alt. ASL [m],Alt. AGL [m],xcoord,ycoord 2 | "1",336958.35,4762858.69,30,nan,-89.0003069511573,43.0009225317008 3 | "2",337046.82,4762858.69,30,nan,-88.9992221426922,43.0009414926987 4 | "3",337046.82,4762849.04,30,nan,-88.9992193268683,43.000854680007 5 | "4",336958.35,4762849.04,30,nan,-89.000304133808,43.0008357190663 6 | "5",336958.35,4762839.4,30,nan,-89.0003013164712,43.0007489064303 7 | "6",337046.82,4762839.4,30,nan,-88.9992165110569,43.0007678673138 8 | "7",337046.82,4762829.75,30,nan,-88.999213695258,43.0006810546191 9 | "8",336958.35,4762829.75,30,nan,-89.0002984991469,43.0006620937929 10 | "9",336958.35,4762820.1,30,nan,-89.0002956818351,43.000575281154 11 | "10",337046.82,4762820.1,30,nan,-88.9992108794717,43.000594241923 12 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: fp2lm # what the user downloads 2 | 3 | # before: 4 | # hooks: 5 | # - go mod tidy 6 | 7 | builds: 8 | - id: cli 9 | binary: fp2lm 10 | main: ./cmd/fp2lm 11 | goos: [windows, linux, darwin] 12 | goarch: [amd64, arm64] # x86_64 + Apple M-series 13 | ldflags: 14 | - -s -w # strip debug for smaller exe 15 | env: 16 | - CGO_ENABLED=0 # Static binaries that work in containers 17 | flags: 18 | - -trimpath 19 | 20 | archives: 21 | - id: zip 22 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 23 | format_overrides: 24 | - goos: windows 25 | format: zip 26 | files: 27 | - README.md 28 | - LICENSE 29 | 30 | checksum: 31 | name_template: "{{ .ProjectName }}_checksums.txt" 32 | 33 | release: 34 | github: 35 | draft: true # you'll publish after a quick test -------------------------------------------------------------------------------- /.github/workflows/debug-release.yml: -------------------------------------------------------------------------------- 1 | name: debug-release 2 | 3 | on: 4 | workflow_dispatch: # This allows manual triggering from the GitHub UI 5 | 6 | jobs: 7 | debug: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-go@v4 17 | with: 18 | go-version: "1.22" 19 | 20 | - name: Initialize dependencies 21 | run: | 22 | go mod tidy 23 | 24 | - name: Setup tmate debug session 25 | uses: mxschmitt/action-tmate@v3 26 | with: 27 | limit-access-to-actor: true 28 | 29 | # This step won't execute until you exit the tmate session 30 | - name: Manual goreleaser run 31 | run: | 32 | go install github.com/goreleaser/goreleaser@latest 33 | goreleaser build --snapshot --clean 34 | ls -la dist/ -------------------------------------------------------------------------------- /cmd/polyorbit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "strconv" 8 | 9 | "flightplan2litchimission/polyorbit" 10 | ) 11 | 12 | func main() { 13 | // Check that a number of sides and a diameter were provided as arguments 14 | if len(os.Args) < 3 { 15 | slog.Error("Insufficient arguments", "usage", "polygon ") 16 | os.Exit(1) 17 | } 18 | 19 | // Parse the number of sides argument as an integer 20 | sides, err := strconv.Atoi(os.Args[1]) 21 | if err != nil { 22 | slog.Error("Error parsing number of sides", "error", err) 23 | os.Exit(1) 24 | } 25 | 26 | // Parse the diameter argument as a floating point value 27 | diameter, err := strconv.ParseFloat(os.Args[2], 64) 28 | if err != nil { 29 | slog.Error("Error parsing diameter", "error", err) 30 | os.Exit(1) 31 | } 32 | 33 | // Create a new RegularPolygon struct 34 | polygon := polyorbit.NewRegularPolygon(sides, diameter) 35 | 36 | // Print the polygon 37 | fmt.Println(polygon) 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 pdfinn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /polyorbit/polyorbit.go: -------------------------------------------------------------------------------- 1 | // Package polyorbit provides utilities for creating regular polygons for drone flight paths. 2 | package polyorbit 3 | 4 | import ( 5 | "fmt" 6 | ) 7 | 8 | // RegularPolygon represents a geographical figure with a specified number of sides 9 | type RegularPolygon struct { 10 | // Sides is the number of sides in the polygon 11 | Sides int 12 | // Diameter is the distance across the polygon through its center 13 | Diameter float64 14 | // CameraDegrees holds the number of degrees a camera at each point of the polygon needs to point to the center 15 | CameraDegrees []float64 16 | } 17 | 18 | // NewRegularPolygon creates a new RegularPolygon with the specified number of sides and diameter. 19 | // It calculates the camera degrees for each point of the polygon. 20 | func NewRegularPolygon(sides int, diameter float64) *RegularPolygon { 21 | var cameraDegrees []float64 22 | for i := 0; i < sides; i++ { 23 | cameraDegrees = append(cameraDegrees, float64(360/sides*i)) 24 | } 25 | 26 | return &RegularPolygon{ 27 | Sides: sides, 28 | Diameter: diameter, 29 | CameraDegrees: cameraDegrees, 30 | } 31 | } 32 | 33 | // String returns a string representation of the RegularPolygon. 34 | func (p RegularPolygon) String() string { 35 | return fmt.Sprintf("RegularPolygon with %d sides and %.2f diameter", p.Sides, p.Diameter) 36 | } 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build clean test lint vet check cross-compile 2 | 3 | # Default target 4 | all: build 5 | 6 | # Build the main binary 7 | build: 8 | go build -o fp2lm ./cmd/fp2lm 9 | 10 | # Clean built files 11 | clean: 12 | rm -f fp2lm *.bin 13 | rm -f TestOutput.csv TestFixed.csv TestFixedASL.csv FinalTest.csv 14 | rm -rf dist/ 15 | 16 | # Run tests 17 | test: 18 | go test -v ./... 19 | 20 | # Run linters 21 | lint: vet 22 | @echo "Linting code..." 23 | go install honnef.co/go/tools/cmd/staticcheck@latest 24 | $(shell go env GOPATH)/bin/staticcheck ./... 25 | go install golang.org/x/vuln/cmd/govulncheck@latest 26 | $(shell go env GOPATH)/bin/govulncheck ./... 27 | 28 | # Run go vet 29 | vet: 30 | @echo "Running go vet..." 31 | go vet ./... 32 | 33 | # Run all checks 34 | check: test lint 35 | 36 | # Run tests with race detection 37 | test-race: 38 | go test -race -v ./... 39 | 40 | # Cross-compile binaries for different platforms 41 | cross-compile: 42 | @echo "Cross-compiling for multiple platforms..." 43 | mkdir -p dist 44 | # Linux (amd64) 45 | GOOS=linux GOARCH=amd64 go build -o dist/fp2lm-linux-amd64 ./cmd/fp2lm 46 | # macOS (amd64) 47 | GOOS=darwin GOARCH=amd64 go build -o dist/fp2lm-darwin-amd64 ./cmd/fp2lm 48 | # macOS (arm64) 49 | GOOS=darwin GOARCH=arm64 go build -o dist/fp2lm-darwin-arm64 ./cmd/fp2lm 50 | # Windows (amd64) 51 | GOOS=windows GOARCH=amd64 go build -o dist/fp2lm-windows-amd64.exe ./cmd/fp2lm 52 | 53 | # Create release archives 54 | release: cross-compile 55 | @echo "Creating release archives..." 56 | cd dist && tar -czf fp2lm-linux-amd64.tar.gz fp2lm-linux-amd64 57 | cd dist && tar -czf fp2lm-darwin-amd64.tar.gz fp2lm-darwin-amd64 58 | cd dist && tar -czf fp2lm-darwin-arm64.tar.gz fp2lm-darwin-arm64 59 | cd dist && zip fp2lm-windows-amd64.zip fp2lm-windows-amd64.exe -------------------------------------------------------------------------------- /lenconv/README.md: -------------------------------------------------------------------------------- 1 | # lenconv package 2 | 3 | This package performs meters and feet distance computations with flag parsing support. 4 | 5 | ## Overview 6 | 7 | The lenconv package provides functions and types for converting between meters and feet, with special support for command-line flag parsing of distance values with units. It is used by the flightplan2litchimission tool to handle distance intervals. 8 | 9 | ## Usage 10 | 11 | ### Basic Conversion 12 | 13 | ```go 14 | import ( 15 | "flightplan2litchimission/lenconv" 16 | "fmt" 17 | ) 18 | 19 | func main() { 20 | // Create a distance in meters 21 | meters := lenconv.Meters(10) 22 | 23 | // Convert to feet (internal function, not exported) 24 | // feet := lenconv.meters2feet(meters) 25 | 26 | // Just display the meters value 27 | fmt.Printf("Distance: %g meters\n", meters) 28 | } 29 | ``` 30 | 31 | ### Command-Line Flag Support 32 | 33 | ```go 34 | import ( 35 | "flag" 36 | "flightplan2litchimission/lenconv" 37 | "fmt" 38 | ) 39 | 40 | func main() { 41 | // Create a flag that accepts distances with units 42 | distance := lenconv.PhotoIntervalFlag("distance", 0, "Enter a distance (e.g., 20m or 50ft)") 43 | 44 | flag.Parse() 45 | 46 | // Use the distance value (always in meters internally) 47 | fmt.Printf("Distance: %g meters\n", *distance) 48 | } 49 | ``` 50 | 51 | The flag will accept values like: 52 | - `20m` (20 meters) 53 | - `30ft` (30 feet, automatically converted to meters) 54 | - `5meters` (5 meters) 55 | - `15feet` (15 feet, automatically converted to meters) 56 | 57 | ## Types 58 | 59 | - `Meters`: Represents a distance value in meters 60 | - `Feet`: Represents a distance value in feet 61 | 62 | ## Key Functions 63 | 64 | - `PhotoIntervalFlag(name string, value Meters, usage string) *Meters`: Creates a flag that accepts distances with units -------------------------------------------------------------------------------- /.github/workflows/simple-build.yml: -------------------------------------------------------------------------------- 1 | name: simple-build-release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | build-release: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-go@v4 17 | with: 18 | go-version: "1.22" 19 | 20 | - name: Add module dependencies 21 | run: | 22 | go get github.com/stretchr/testify 23 | go mod tidy 24 | 25 | - name: Build executables 26 | run: | 27 | echo "Building Windows executable..." 28 | GOOS=windows GOARCH=amd64 go build -o fp2lm.exe ./cmd/fp2lm 29 | echo "Building macOS executable..." 30 | GOOS=darwin GOARCH=amd64 go build -o fp2lm_mac ./cmd/fp2lm 31 | echo "Building Linux executable..." 32 | GOOS=linux GOARCH=amd64 go build -o fp2lm_linux ./cmd/fp2lm 33 | 34 | # Verify the binaries were created 35 | ls -la fp2lm* 36 | 37 | - name: Package executables 38 | run: | 39 | mkdir -p release 40 | # Windows 41 | zip -j release/fp2lm_windows_amd64.zip fp2lm.exe README.md LICENSE 42 | # macOS 43 | tar -czf release/fp2lm_darwin_amd64.tar.gz fp2lm_mac README.md LICENSE 44 | # Linux 45 | tar -czf release/fp2lm_linux_amd64.tar.gz fp2lm_linux README.md LICENSE 46 | 47 | # Verify packages 48 | ls -la release/ 49 | 50 | - name: Create GitHub Release 51 | uses: softprops/action-gh-release@v1 52 | with: 53 | name: Release ${{ github.ref_name }} 54 | files: | 55 | release/fp2lm_windows_amd64.zip 56 | release/fp2lm_darwin_amd64.tar.gz 57 | release/fp2lm_linux_amd64.tar.gz -------------------------------------------------------------------------------- /lenconv/lenconv.go: -------------------------------------------------------------------------------- 1 | // Package lenconv performs meters and feet distance computations. 2 | // 3 | // This package provides functions and types for converting between meters and feet, 4 | // with special support for command-line flag parsing of distance values with units. 5 | // It is used by the flightplan2litchimission tool to handle distance intervals. 6 | package lenconv 7 | 8 | import ( 9 | "flag" 10 | "fmt" 11 | ) 12 | 13 | // Meters represents a distance value in meters 14 | type Meters float64 15 | 16 | // Feet represents a distance value in feet 17 | type Feet float64 18 | 19 | // Convert feet to meters with standard conversion factor 20 | func feet2meters(f Feet) Meters { return Meters(f * 0.3048) } 21 | 22 | // String returns the string representation of Meters 23 | func (m Meters) String() string { return fmt.Sprintf("%g", m) } 24 | 25 | //type Value interface { 26 | // String() string 27 | // Set() error 28 | //} 29 | 30 | // photoIntervalFlag implements the flag.Value interface for photo interval distances 31 | type photoIntervalFlag struct{ Meters } 32 | 33 | // Set parses a string value with units into a distance and stores it 34 | // Valid formats include "10m" for meters or "30ft" for feet 35 | func (f *photoIntervalFlag) Set(s string) error { 36 | var unit string 37 | var value float64 38 | fmt.Sscanf(s, "%f%s", &value, &unit) 39 | switch unit { 40 | case "M", "m", "Meters", "meters": 41 | f.Meters = Meters(value) 42 | return nil 43 | case "Ft", "ft", "Feet", "feet": 44 | f.Meters = feet2meters(Feet(value)) 45 | return nil 46 | } 47 | return fmt.Errorf("invalid units %q", s) 48 | } 49 | 50 | // PhotoIntervalFlag creates a flag that accepts distances with units 51 | // It can be used to specify photo interval distances in meters or feet 52 | func PhotoIntervalFlag(name string, value Meters, usage string) *Meters { 53 | f := photoIntervalFlag{value} 54 | flag.CommandLine.Var(&f, name, usage) 55 | return &f.Meters 56 | } 57 | -------------------------------------------------------------------------------- /fp2lm/README.md: -------------------------------------------------------------------------------- 1 | # fp2lm package 2 | 3 | This package implements the core functionality for the flightplan2litchimission converter. 4 | 5 | ## Overview 6 | 7 | The fp2lm package provides functions for converting waypoint data from Flight Planner CSV format to Litchi Mission Hub CSV format, making it compatible with DJI drones. 8 | 9 | ## Usage 10 | 11 | ```go 12 | import ( 13 | "flightplan2litchimission/fp2lm" 14 | "os" 15 | ) 16 | 17 | func main() { 18 | // Create converter options 19 | options := &fp2lm.ConverterOptions{ 20 | AltitudeMode: "agl", // "agl" or "asl" 21 | PhotoInterval: 20, // Distance between photos in meters 22 | GimbalPitch: -90, // Camera angle in degrees 23 | MaxAltitudeAGL: 120, // Maximum allowed altitude in meters 24 | } 25 | 26 | // Convert from input to output 27 | err := fp2lm.Process(os.Stdin, os.Stdout, options) 28 | if err != nil { 29 | // Handle error 30 | } 31 | } 32 | ``` 33 | 34 | ## Key Functions 35 | 36 | - `Process(input io.Reader, output io.Writer, options *ConverterOptions) error`: Main conversion function that processes input CSV data and writes Litchi format. 37 | - `CalculateBearing(lat1, lon1, lat2, lon2 float64) float64`: Calculates the initial bearing between two geographic points. 38 | - `DefaultOptions() *ConverterOptions`: Returns recommended default settings for the converter. 39 | 40 | ## Options 41 | 42 | The `ConverterOptions` struct configures the conversion behavior: 43 | 44 | - `AltitudeMode`: Determines how altitude values are interpreted. Use "agl" for relative altitudes (Above Ground Level) or "asl" for absolute altitudes (Above Sea Level). 45 | - `PhotoInterval`: Specifies the distance between photos in meters. 46 | - `GimbalPitch`: Sets the camera angle in degrees (between -90 and 0). 47 | - `MaxAltitudeAGL`: Specifies the maximum allowed altitude when in AGL mode, typically set to local regulatory limits. 48 | 49 | By default, `fp2lm` adds a "take photo" action at each waypoint so every point along the mission captures an image, even when using distance-based intervals. 50 | -------------------------------------------------------------------------------- /fp2lm/testdata/examples/TestOutput.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,NaN,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 3 | 43.0009415,-88.9992221,NaN,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 4 | 43.0008547,-88.9992193,NaN,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 5 | 43.0008357,-89.0003041,NaN,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 6 | 43.0007489,-89.0003013,NaN,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 7 | 43.0007679,-88.9992165,NaN,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 8 | 43.0006811,-88.9992137,NaN,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 9 | 43.0006621,-89.0002985,NaN,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 10 | 43.0005753,-89.0002957,NaN,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 11 | 43.0005942,-88.9992109,NaN,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,20.0 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/new_golden.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 3 | 43.0009415,-88.9992221,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 4 | 43.0008547,-88.9992193,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 5 | 43.0008357,-89.0003041,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 6 | 43.0007489,-89.0003013,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 7 | 43.0007679,-88.9992165,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 8 | 43.0006811,-88.9992137,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 9 | 43.0006621,-89.0002985,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 10 | 43.0005753,-89.0002957,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 11 | 43.0005942,-88.9992109,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/litchi_golden.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 3 | 43.0009415,-88.9992221,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 4 | 43.0008547,-88.9992193,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 5 | 43.0008357,-89.0003041,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 6 | 43.0007489,-89.0003013,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 7 | 43.0007679,-88.9992165,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 8 | 43.0006811,-88.9992137,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 9 | 43.0006621,-89.0002985,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 10 | 43.0005753,-89.0002957,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 11 | 43.0005942,-88.9992109,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,0.0 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/examples/FinalTest.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,30.000,88.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 3 | 43.0009415,-88.9992221,30.000,178.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 4 | 43.0008547,-88.9992193,30.000,268.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 5 | 43.0008357,-89.0003041,30.000,178.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 6 | 43.0007489,-89.0003013,30.000,88.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 7 | 43.0007679,-88.9992165,30.000,178.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 8 | 43.0006811,-88.9992137,30.000,268.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 9 | 43.0006621,-89.0002985,30.000,178.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 10 | 43.0005753,-89.0002957,30.000,88.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 11 | 43.0005942,-88.9992109,30.000,88.6,0.0,0,0,-75.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,15.0 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/examples/TestFixed.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 3 | 43.0009415,-88.9992221,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 4 | 43.0008547,-88.9992193,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 5 | 43.0008357,-89.0003041,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 6 | 43.0007489,-89.0003013,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 7 | 43.0007679,-88.9992165,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 8 | 43.0006811,-88.9992137,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 9 | 43.0006621,-89.0002985,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 10 | 43.0005753,-89.0002957,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 11 | 43.0005942,-88.9992109,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/examples/TestFixedASL.csv: -------------------------------------------------------------------------------- 1 | latitude,longitude,altitude(m),heading(deg),curvesize(m),rotationdir,gimbalmode,gimbalpitchangle,actiontype1,actionparam1,actiontype2,actionparam2,actiontype3,actionparam3,actiontype4,actionparam4,actiontype5,actionparam5,actiontype6,actionparam6,actiontype7,actionparam7,actiontype8,actionparam8,actiontype9,actionparam9,actiontype10,actionparam10,actiontype11,actionparam11,actiontype12,actionparam12,actiontype13,actionparam13,actiontype14,actionparam14,actiontype15,actionparam15,altitudemode,speed(m/s),poi_latitude,poi_longitude,poi_altitude(m),poi_altitudemode,photo_timeinterval,photo_distinterval 2 | 43.0009225,-89.0003070,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 3 | 43.0009415,-88.9992221,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 4 | 43.0008547,-88.9992193,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 5 | 43.0008357,-89.0003041,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 6 | 43.0007489,-89.0003013,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 7 | 43.0007679,-88.9992165,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 8 | 43.0006811,-88.9992137,30.000,268.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 9 | 43.0006621,-89.0002985,30.000,178.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 10 | 43.0005753,-89.0002957,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 11 | 43.0005942,-88.9992109,30.000,88.6,0.0,0,0,-90.0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0,0.0000000,0.0000000,0.000,0,-1.0,10.0 12 | -------------------------------------------------------------------------------- /missioncsv/README.md: -------------------------------------------------------------------------------- 1 | # missioncsv package 2 | 3 | This package provides utilities for formatting and writing mission CSV files for various drone platforms including Litchi Mission Hub and DJI Pilot 2. 4 | 5 | ## Overview 6 | 7 | The missioncsv package implements CSV writing functionality for drone missions, focusing on the Litchi Mission Hub format. It provides structured types for waypoints, points of interest, and actions, along with a CSV writer to generate properly formatted output. 8 | 9 | ## Usage 10 | 11 | ```go 12 | import ( 13 | "flightplan2litchimission/missioncsv" 14 | "os" 15 | ) 16 | 17 | func main() { 18 | // Create a writer for the output 19 | writer := missioncsv.NewWriter(os.Stdout) 20 | 21 | // Write the standard Litchi mission header 22 | err := writer.WriteLitchiHeader() 23 | if err != nil { 24 | // Handle error 25 | } 26 | 27 | // Create and configure a waypoint 28 | waypoint := missioncsv.NewLitchiWaypoint() 29 | waypoint.Point.Latitude = 43.0009225 30 | waypoint.Point.Longitude = -89.0003070 31 | waypoint.Point.Altitude = 30.0 32 | waypoint.Heading = 90.0 33 | waypoint.GimbalPitch = -90.0 34 | waypoint.AltitudeMode = 1 // 1 for relative (AGL), 0 for absolute (ASL) 35 | 36 | // Add a "take photo" action 37 | waypoint.Actions = []missioncsv.Action{ 38 | {Type: 1, Param: 0}, 39 | } 40 | 41 | // Write the waypoint to the output 42 | err = writer.WriteLitchiWaypoint(waypoint) 43 | if err != nil { 44 | // Handle error 45 | } 46 | 47 | // Make sure to flush the buffer when done 48 | writer.Flush() 49 | } 50 | ``` 51 | 52 | ## Types 53 | 54 | - `Point`: Represents geographic coordinates (latitude, longitude, altitude) 55 | - `POI`: Represents a Point of Interest 56 | - `LitchiWaypoint`: Contains all data needed for a Litchi mission waypoint 57 | - `Action`: Represents an action to perform at a waypoint (e.g., take photo) 58 | - `Writer`: Handles writing waypoints to a Litchi-compatible CSV file 59 | 60 | ## Key Functions 61 | 62 | - `NewWriter(w io.Writer) *Writer`: Creates a new mission CSV writer 63 | - `WriteLitchiHeader() error`: Writes the standard Litchi mission header 64 | - `WriteLitchiWaypoint(wp *LitchiWaypoint) error`: Writes a single waypoint in Litchi format 65 | - `NewLitchiWaypoint() *LitchiWaypoint`: Creates a new waypoint with default values -------------------------------------------------------------------------------- /.github/workflows/direct-build.yml: -------------------------------------------------------------------------------- 1 | name: direct-build-release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.22" 21 | 22 | - name: Force create go.sum 23 | run: | 24 | # Add a temporary dependency to force go.sum creation 25 | go get github.com/stretchr/testify 26 | go mod tidy 27 | 28 | - name: Show go module files (debug) 29 | run: | 30 | ls -la 31 | cat go.mod || echo "go.mod not found" 32 | cat go.sum || echo "go.sum not found" 33 | 34 | - name: Set VERSION from git tag 35 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 36 | 37 | - name: Build for Windows 38 | run: | 39 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o fp2lm.exe ./cmd/fp2lm 40 | 41 | - name: Build for macOS (Intel) 42 | run: | 43 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o fp2lm_darwin_amd64 ./cmd/fp2lm 44 | 45 | - name: Build for Linux 46 | run: | 47 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o fp2lm_linux_amd64 ./cmd/fp2lm 48 | 49 | - name: Create ZIP for Windows 50 | run: | 51 | zip -j fp2lm_windows_amd64.zip fp2lm.exe README.md LICENSE 52 | 53 | - name: Create TAR for macOS 54 | run: | 55 | tar czf fp2lm_darwin_amd64.tar.gz fp2lm_darwin_amd64 README.md LICENSE 56 | 57 | - name: Create TAR for Linux 58 | run: | 59 | tar czf fp2lm_linux_amd64.tar.gz fp2lm_linux_amd64 README.md LICENSE 60 | 61 | - name: List files (debug) 62 | run: | 63 | echo "Current directory contents:" 64 | ls -la 65 | echo "Files to be uploaded:" 66 | ls -la fp2lm_windows_amd64.zip fp2lm_darwin_amd64.tar.gz fp2lm_linux_amd64.tar.gz || echo "Some files missing!" 67 | 68 | - name: Create Release 69 | uses: softprops/action-gh-release@v1 70 | with: 71 | name: Release ${{ env.VERSION }} 72 | draft: true 73 | files: | 74 | fp2lm_windows_amd64.zip 75 | fp2lm_darwin_amd64.tar.gz 76 | fp2lm_linux_amd64.tar.gz -------------------------------------------------------------------------------- /fp2lm/testdata/examples/LitchiMissionTest.csv: -------------------------------------------------------------------------------- 1 | latitude, longitude, altitude(m), heading(deg), curvesize(m), rotationdir, gimbalmode, gimbalpitchangle, actiontype1, actionparam1, actiontype2, actionparam2, actiontype3, actionparam3, actiontype4, actionparam4, actiontype5, actionparam5, actiontype6, actionparam6, actiontype7, actionparam7, actiontype8, actionparam8, actionparam9, actionparam9, actiontype10, actionparam10, actiontype11, actionparam11, actiontype12, actionparam12, actiontype13, actionparam13, actiontype14, actionparam14, actiontype15, actionparam15, altitudemode, speed(m/s), poi_latitude, poi_longitude, poi_altitude(m), poi_altitudemode, photo_timeinterval, photo_distinterval 2 | 43.0009225317008, -89.0003069511573, 30, 88.630554, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 3 | 43.0009414926987, -88.9992221426922, 30, 178.64111, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 4 | 43.000854680007, -88.9992193268683, 30, 268.6313, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 5 | 43.0008357190663, -89.000304133808, 30, 178.64038, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 6 | 43.0007489064303, -89.0003013164712, 30, 88.63056, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 7 | 43.0007678673138, -88.9992165110569, 30, 178.64111, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 8 | 43.0006810546191, -88.999213695258, 30, 268.63132, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 9 | 43.0006620937929, -89.0002984991469, 30, 178.64038, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 10 | 43.000575281154, -89.0002956818351, 30, 88.63057, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 11 | 43.000594241923, -88.9992108794717, 30, 88.63057, 0, 0, 0, -90, 1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 12 | -------------------------------------------------------------------------------- /fp2lm/testdata/examples/LitchiMissionTest2.csv: -------------------------------------------------------------------------------- 1 | latitude, longitude, altitude(m), heading(deg), curvesize(m), rotationdir, gimbalmode, gimbalpitchangle, actiontype1, actionparam1, actiontype2, actionparam2, actiontype3, actionparam3, actiontype4, actionparam4, actiontype5, actionparam5, actiontype6, actionparam6, actiontype7, actionparam7, actiontype8, actionparam8, actionparam9, actionparam9, actiontype10, actionparam10, actiontype11, actionparam11, actiontype12, actionparam12, actiontype13, actionparam13, actiontype14, actionparam14, actiontype15, actionparam15, altitudemode, speed(m/s), poi_latitude, poi_longitude, poi_altitude(m), poi_altitudemode, photo_timeinterval, photo_distinterval 2 | 43.0009225317008, -89.0003069511573, 30, 88.630554, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 3 | 43.0009414926987, -88.9992221426922, 30, 178.64111, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 4 | 43.000854680007, -88.9992193268683, 30, 268.6313, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 5 | 43.0008357190663, -89.000304133808, 30, 178.64038, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 6 | 43.0007489064303, -89.0003013164712, 30, 88.63056, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 7 | 43.0007678673138, -88.9992165110569, 30, 178.64111, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 8 | 43.0006810546191, -88.999213695258, 30, 268.63132, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 9 | 43.0006620937929, -89.0002984991469, 30, 178.64038, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 10 | 43.000575281154, -89.0002956818351, 30, 88.63057, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 11 | 43.000594241923, -88.9992108794717, 30, 88.63057, 0, 0, 0, -90, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, 1, 0, 0, 0, 0, 0, -1, 20 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Go CI/CD 2 | 3 | on: 4 | # Regular builds 5 | push: 6 | branches: [ "main" ] 7 | tags: ["v*"] 8 | pull_request: 9 | branches: [ "main" ] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.22" 21 | 22 | - name: Build 23 | run: go build -v ./... 24 | 25 | - name: Test 26 | run: go test -v ./... 27 | 28 | release: 29 | runs-on: ubuntu-latest 30 | # Only run this job on tag pushes 31 | if: startsWith(github.ref, 'refs/tags/v') 32 | needs: build 33 | 34 | permissions: 35 | contents: write # to create the Release 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 # Required for GoReleaser to build properly 41 | 42 | - uses: actions/setup-go@v4 43 | with: 44 | go-version: "1.22" 45 | 46 | - name: Force create go.sum 47 | run: | 48 | # Add a temporary dependency to force go.sum creation 49 | go get github.com/stretchr/testify 50 | go mod tidy 51 | 52 | - name: Show go module files (debug) 53 | run: | 54 | ls -la 55 | cat go.mod || echo "go.mod not found" 56 | cat go.sum || echo "go.sum not found" 57 | 58 | - name: Set environment variables 59 | run: | 60 | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 61 | echo "GORELEASER_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 62 | 63 | - name: Install and run GoReleaser directly 64 | run: | 65 | echo "Installing GoReleaser..." 66 | # Install a specific version for stability 67 | go install github.com/goreleaser/goreleaser@v1.21.0 68 | 69 | echo "Running GoReleaser..." 70 | # Run with maximum debug output 71 | goreleaser build --snapshot --rm-dist || echo "GoReleaser build failed" 72 | goreleaser release --clean --debug || echo "GoReleaser release failed but continuing" 73 | 74 | - name: List dist directory contents (debug) 75 | run: | 76 | echo "Dist directory contents:" 77 | mkdir -p dist # Ensure dist exists even if GoReleaser failed 78 | ls -la dist/ || echo "dist directory not found!" 79 | 80 | - name: Create fallback release if needed 81 | if: failure() 82 | uses: softprops/action-gh-release@v1 83 | with: 84 | name: Release Fallback 85 | draft: true 86 | fail_on_unmatched_files: false 87 | files: dist/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | 154 | # ignore the test file 155 | FlightplannerMission.csv 156 | LitchiMissionOriginal.csv 157 | ./fp2lm 158 | LitchiMission.csv 159 | 160 | # Binary files 161 | *.bin 162 | bin/ 163 | dist/ 164 | fp2lm 165 | main 166 | fp2lm.exe 167 | **/fp2lm 168 | 169 | # Ignore binary files in any directory 170 | *.exe 171 | *.dll 172 | *.so 173 | *.dylib 174 | *.out 175 | 176 | # Compiled binaries for any platform 177 | **/bin/ 178 | 179 | # Binaries for programs and plugins 180 | # (Go standard .gitignore entries) 181 | *.exe 182 | *.exe~ 183 | *.dll 184 | *.so 185 | *.dylib 186 | 187 | # Output of the go coverage tool 188 | *.cover 189 | coverage.txt 190 | profile.out 191 | 192 | # Go workspace file 193 | go.work 194 | 195 | # Test output files 196 | TestOutput.csv 197 | TestFixed.csv 198 | TestFixedASL.csv 199 | FinalTest.csv 200 | *_output.csv -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: '1.21' 19 | check-latest: true 20 | 21 | - name: Verify dependencies 22 | run: go mod verify 23 | 24 | - name: Run tests 25 | run: make test 26 | 27 | - name: Run vet 28 | run: make vet 29 | 30 | - name: Test Golden Files 31 | run: | 32 | mkdir -p testdata 33 | cp fp2lm/testdata/litchi_golden.csv testdata/litchi_golden_original.csv 34 | go run ./cmd/fp2lm < fp2lm/testdata/FlightplannerMission.csv > testdata/litchi_generated.csv 35 | if ! diff -u fp2lm/testdata/litchi_golden.csv testdata/litchi_generated.csv; then 36 | echo "Golden file does not match generated output" 37 | exit 1 38 | fi 39 | 40 | - name: Test flag-driven behavior 41 | run: | 42 | # Test with ASL altitude mode 43 | go run ./cmd/fp2lm -altitude-mode=asl < fp2lm/testdata/FlightplannerMission.csv > testdata/asl_output.csv 44 | grep -q 'altitudemode,0,' testdata/asl_output.csv || (echo "ASL mode failed" && exit 1) 45 | 46 | # Test with different pitch 47 | go run ./cmd/fp2lm -pitch=-80 < fp2lm/testdata/FlightplannerMission.csv > testdata/pitch_output.csv 48 | grep -q 'gimbalpitchangle,-80.0,' testdata/pitch_output.csv || (echo "Pitch setting failed" && exit 1) 49 | 50 | # Test with interval 51 | go run ./cmd/fp2lm -d 10m < fp2lm/testdata/FlightplannerMission.csv > testdata/interval_output.csv 52 | grep -q 'photo_distinterval,10.0,' testdata/interval_output.csv || (echo "Interval setting failed" && exit 1) 53 | 54 | # Test with ASL mode to verify altitude processing 55 | go run ./cmd/fp2lm -altitude-mode=asl < fp2lm/testdata/FlightplannerMission.csv > testdata/altitude_output.csv 56 | grep -q 'altitudemode,0,' testdata/altitude_output.csv || (echo "ASL altitude mode failed" && exit 1) 57 | 58 | # Test with output file 59 | go run ./cmd/fp2lm -output=testdata/output_file.csv < fp2lm/testdata/FlightplannerMission.csv 60 | [ -f testdata/output_file.csv ] || (echo "Output file not created" && exit 1) 61 | 62 | - name: Test CSV round-trip conversion 63 | run: | 64 | # Generate a Litchi CSV with AGL mode 65 | go run ./cmd/fp2lm < fp2lm/testdata/FlightplannerMission.csv > testdata/first_pass.csv 66 | 67 | # Feed that CSV back in with ASL mode 68 | go run ./cmd/fp2lm -altitude-mode=asl < testdata/first_pass.csv > testdata/second_pass.csv 69 | 70 | # Create a verification script 71 | cat > verify.py << 'EOF' 72 | #!/usr/bin/env python3 73 | import csv 74 | import sys 75 | import math 76 | 77 | def read_coords(file): 78 | data = [] 79 | with open(file, 'r') as f: 80 | reader = csv.reader(f) 81 | next(reader) # Skip header 82 | for row in reader: 83 | if len(row) >= 3: 84 | data.append([float(row[0]), float(row[1]), float(row[2])]) 85 | return data 86 | 87 | first = read_coords('testdata/first_pass.csv') 88 | second = read_coords('testdata/second_pass.csv') 89 | 90 | if len(first) != len(second): 91 | print(f'Waypoint count mismatch: {len(first)} vs {len(second)}') 92 | sys.exit(1) 93 | 94 | for i, (f, s) in enumerate(zip(first, second)): 95 | for j in range(3): 96 | if abs(f[j] - s[j]) > 0.0001: 97 | print(f'Waypoint {i}, coordinate {j}: {f[j]} vs {s[j]}') 98 | sys.exit(1) 99 | 100 | print('Round-trip test passed') 101 | EOF 102 | 103 | chmod +x verify.py 104 | ./verify.py 105 | 106 | release: 107 | needs: test 108 | runs-on: ubuntu-latest 109 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 110 | steps: 111 | - uses: actions/checkout@v3 112 | 113 | - name: Set up Go 114 | uses: actions/setup-go@v4 115 | with: 116 | go-version: '1.21' 117 | check-latest: true 118 | 119 | - name: Build release artifacts 120 | run: make release 121 | 122 | - name: Upload Linux artifact 123 | uses: actions/upload-artifact@v2 124 | with: 125 | name: fp2lm-linux-amd64 126 | path: dist/fp2lm-linux-amd64.tar.gz 127 | 128 | - name: Upload macOS Intel artifact 129 | uses: actions/upload-artifact@v2 130 | with: 131 | name: fp2lm-darwin-amd64 132 | path: dist/fp2lm-darwin-amd64.tar.gz 133 | 134 | - name: Upload macOS ARM artifact 135 | uses: actions/upload-artifact@v2 136 | with: 137 | name: fp2lm-darwin-arm64 138 | path: dist/fp2lm-darwin-arm64.tar.gz 139 | 140 | - name: Upload Windows artifact 141 | uses: actions/upload-artifact@v2 142 | with: 143 | name: fp2lm-windows-amd64 144 | path: dist/fp2lm-windows-amd64.zip -------------------------------------------------------------------------------- /missioncsv/writer.go: -------------------------------------------------------------------------------- 1 | // Package missioncsv provides utilities for formatting and writing mission CSV files 2 | // for various drone platforms including Litchi Mission Hub and DJI Pilot 2. 3 | package missioncsv 4 | 5 | import ( 6 | "encoding/csv" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | ) 11 | 12 | // Point represents a waypoint with its geographic coordinates 13 | type Point struct { 14 | Latitude float64 15 | Longitude float64 16 | Altitude float64 17 | } 18 | 19 | // POI represents a point of interest 20 | type POI struct { 21 | Latitude float64 22 | Longitude float64 23 | Altitude float64 24 | } 25 | 26 | // LitchiWaypoint contains all data needed for a Litchi mission waypoint 27 | type LitchiWaypoint struct { 28 | // Geographic coordinates 29 | Point Point 30 | // Camera orientation 31 | Heading float32 32 | GimbalPitch float32 33 | // Curve properties 34 | CurveSize float32 35 | RotationDir int8 36 | // Control settings 37 | GimbalMode int8 38 | AltitudeMode int8 39 | Speed float32 40 | // Potential point of interest 41 | POI POI 42 | POIAltMode int8 43 | // Photo interval settings 44 | PhotoTimeInterval float32 45 | PhotoDistInterval float32 46 | // Actions (up to 15) 47 | Actions []Action 48 | } 49 | 50 | // Action represents an action to perform at a waypoint 51 | type Action struct { 52 | Type int8 53 | Param int8 54 | } 55 | 56 | // Writer handles writing waypoints to a Litchi-compatible CSV file 57 | type Writer struct { 58 | csvWriter *csv.Writer 59 | } 60 | 61 | // NewWriter creates a new mission CSV writer that outputs to the provided writer 62 | func NewWriter(w io.Writer) *Writer { 63 | csvWriter := csv.NewWriter(w) 64 | csvWriter.Comma = ',' 65 | return &Writer{csvWriter: csvWriter} 66 | } 67 | 68 | // WriteLitchiHeader writes the standard Litchi mission header 69 | func (w *Writer) WriteLitchiHeader() error { 70 | headerFields := []string{ 71 | "latitude", "longitude", "altitude(m)", "heading(deg)", "curvesize(m)", "rotationdir", "gimbalmode", 72 | "gimbalpitchangle", "actiontype1", "actionparam1", "actiontype2", "actionparam2", "actiontype3", "actionparam3", 73 | "actiontype4", "actionparam4", "actiontype5", "actionparam5", "actiontype6", "actionparam6", "actiontype7", 74 | "actionparam7", "actiontype8", "actionparam8", "actiontype9", "actionparam9", "actiontype10", "actionparam10", 75 | "actiontype11", "actionparam11", "actiontype12", "actionparam12", "actiontype13", "actionparam13", "actiontype14", 76 | "actionparam14", "actiontype15", "actionparam15", "altitudemode", "speed(m/s)", "poi_latitude", "poi_longitude", 77 | "poi_altitude(m)", "poi_altitudemode", "photo_timeinterval", "photo_distinterval", 78 | } 79 | return w.csvWriter.Write(headerFields) 80 | } 81 | 82 | // WriteLitchiWaypoint writes a single waypoint in Litchi format 83 | func (w *Writer) WriteLitchiWaypoint(wp *LitchiWaypoint) error { 84 | // Ensure we have at least 15 actions (padding with zeros if needed) 85 | actions := wp.Actions 86 | for len(actions) < 15 { 87 | actions = append(actions, Action{Type: 0, Param: 0}) 88 | } 89 | 90 | // Truncate if we somehow have more than 15 actions 91 | if len(actions) > 15 { 92 | actions = actions[:15] 93 | slog.Warn("Truncated excess actions for waypoint", "latitude", wp.Point.Latitude, "longitude", wp.Point.Longitude) 94 | } 95 | 96 | row := []string{ 97 | fmt.Sprintf("%.7f", wp.Point.Latitude), 98 | fmt.Sprintf("%.7f", wp.Point.Longitude), 99 | fmt.Sprintf("%.3f", wp.Point.Altitude), 100 | fmt.Sprintf("%.1f", wp.Heading), 101 | fmt.Sprintf("%.1f", wp.CurveSize), 102 | fmt.Sprintf("%d", wp.RotationDir), 103 | fmt.Sprintf("%d", wp.GimbalMode), 104 | fmt.Sprintf("%.1f", wp.GimbalPitch), 105 | } 106 | 107 | // Add all 15 actions 108 | for i := 0; i < 15; i++ { 109 | row = append(row, 110 | fmt.Sprintf("%d", actions[i].Type), 111 | fmt.Sprintf("%d", actions[i].Param), 112 | ) 113 | } 114 | 115 | // Add remaining fields 116 | row = append(row, 117 | fmt.Sprintf("%d", wp.AltitudeMode), 118 | fmt.Sprintf("%.1f", wp.Speed), 119 | fmt.Sprintf("%.7f", wp.POI.Latitude), 120 | fmt.Sprintf("%.7f", wp.POI.Longitude), 121 | fmt.Sprintf("%.3f", wp.POI.Altitude), 122 | fmt.Sprintf("%d", wp.POIAltMode), 123 | fmt.Sprintf("%.1f", wp.PhotoTimeInterval), 124 | fmt.Sprintf("%.1f", wp.PhotoDistInterval), 125 | ) 126 | 127 | return w.csvWriter.Write(row) 128 | } 129 | 130 | // Flush writes any buffered data to the underlying io.Writer 131 | func (w *Writer) Flush() { 132 | w.csvWriter.Flush() 133 | } 134 | 135 | // Error returns any error that occurred during writing 136 | func (w *Writer) Error() error { 137 | return w.csvWriter.Error() 138 | } 139 | 140 | // CreateDefaultAction creates a default action with specified type and param 141 | func CreateDefaultAction(actionType, param int8) Action { 142 | return Action{Type: actionType, Param: param} 143 | } 144 | 145 | // NewLitchiWaypoint creates a new waypoint with default values 146 | func NewLitchiWaypoint() *LitchiWaypoint { 147 | return &LitchiWaypoint{ 148 | Point: Point{ 149 | Latitude: 0, 150 | Longitude: 0, 151 | Altitude: 0, 152 | }, 153 | Heading: 360, 154 | CurveSize: 0, 155 | RotationDir: 0, 156 | GimbalMode: 0, 157 | GimbalPitch: -90, 158 | AltitudeMode: 1, // Default to relative (AGL) 159 | Speed: 0, 160 | POI: POI{ 161 | Latitude: 0, 162 | Longitude: 0, 163 | Altitude: 0, 164 | }, 165 | POIAltMode: 0, 166 | PhotoTimeInterval: -1, 167 | PhotoDistInterval: -1, 168 | Actions: []Action{ 169 | {Type: 1, Param: 0}, // Default take photo action 170 | }, 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /fp2lm/fp2lm_test.go: -------------------------------------------------------------------------------- 1 | package fp2lm_test 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "flightplan2litchimission/fp2lm" 7 | "math" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | //go:embed testdata/litchi_golden.csv 13 | var goldenFileData []byte 14 | 15 | //go:embed testdata/FlightplannerMission.csv 16 | var flightplannerMissionData []byte 17 | 18 | // normalizeLineEndings replaces all occurrences of \r\n with \n to normalize line endings 19 | func normalizeLineEndings(s string) string { 20 | return strings.ReplaceAll(s, "\r\n", "\n") 21 | } 22 | 23 | // canonicalizeCSV standardizes a CSV string by trimming whitespace and normalizing line endings 24 | func canonicalizeCSV(s string) string { 25 | s = normalizeLineEndings(s) 26 | lines := strings.Split(s, "\n") 27 | 28 | // Remove trailing empty lines 29 | for len(lines) > 0 && lines[len(lines)-1] == "" { 30 | lines = lines[:len(lines)-1] 31 | } 32 | 33 | // Normalize each line 34 | for i, line := range lines { 35 | // Trim spaces and replace spaces after commas 36 | lines[i] = strings.ReplaceAll(strings.TrimSpace(line), ", ", ",") 37 | } 38 | 39 | return strings.Join(lines, "\n") 40 | } 41 | 42 | // TestProcess checks that the converter produces the expected output 43 | func TestProcess(t *testing.T) { 44 | // Use embedded test data instead of file paths 45 | inputReader := bytes.NewReader(flightplannerMissionData) 46 | 47 | // Capture the actual output 48 | var output bytes.Buffer 49 | 50 | // Create options 51 | options := &fp2lm.ConverterOptions{ 52 | AltitudeMode: "agl", 53 | GimbalPitch: -90, 54 | } 55 | 56 | // Process the input using the exported Process function 57 | err := fp2lm.Process(inputReader, &output, options) 58 | if err != nil { 59 | t.Fatalf("Failed to process input: %v", err) 60 | } 61 | 62 | // Canonicalize both outputs for comparison 63 | actualOutput := canonicalizeCSV(output.String()) 64 | expectedOutput := canonicalizeCSV(string(goldenFileData)) 65 | 66 | // Compare normalized outputs 67 | if actualOutput != expectedOutput { 68 | // Split into lines for more helpful error message 69 | actualLines := strings.Split(actualOutput, "\n") 70 | expectedLines := strings.Split(expectedOutput, "\n") 71 | 72 | // Find first difference 73 | minLen := len(actualLines) 74 | if len(expectedLines) < minLen { 75 | minLen = len(expectedLines) 76 | t.Errorf("Output has %d lines, golden file has %d lines", len(actualLines), len(expectedLines)) 77 | } 78 | 79 | for i := 0; i < minLen; i++ { 80 | if actualLines[i] != expectedLines[i] { 81 | t.Errorf("Mismatch on line %d:\nExpected: %s\nGot: %s", i+1, expectedLines[i], actualLines[i]) 82 | break 83 | } 84 | } 85 | 86 | if len(actualLines) > len(expectedLines) { 87 | t.Errorf("Extra lines in actual output starting at line %d", len(expectedLines)+1) 88 | } else if len(actualLines) < len(expectedLines) { 89 | t.Errorf("Missing lines in actual output starting at line %d", len(actualLines)+1) 90 | } 91 | } 92 | } 93 | 94 | // TestCSVRoundTrip tests that a CSV file can be converted and then converted back 95 | func TestCSVRoundTrip(t *testing.T) { 96 | // First pass: convert FlightplannerMission to Litchi format with AGL mode 97 | inputReader := bytes.NewReader(flightplannerMissionData) 98 | var firstOutput bytes.Buffer 99 | 100 | options := &fp2lm.ConverterOptions{ 101 | AltitudeMode: "agl", 102 | GimbalPitch: -90, 103 | } 104 | 105 | err := fp2lm.Process(inputReader, &firstOutput, options) 106 | if err != nil { 107 | t.Fatalf("Failed first conversion: %v", err) 108 | } 109 | 110 | // For this test, we'll just verify key fields from the first output 111 | // (Litchi format back to Litchi format isn't the primary use case, 112 | // and detecting the Litchi format would require additional parser code) 113 | 114 | firstLines := strings.Split(canonicalizeCSV(firstOutput.String()), "\n") 115 | 116 | if len(firstLines) <= 1 { 117 | t.Fatalf("No waypoints generated in first pass") 118 | } 119 | 120 | // Skip header and check that the first waypoint has the right type of data 121 | firstFields := strings.Split(firstLines[1], ",") 122 | if len(firstFields) < 5 { 123 | t.Fatalf("First line has insufficient fields: %d", len(firstFields)) 124 | } 125 | 126 | // Parse latitude 127 | lat, _, err := fp2lm.ParseField(firstFields[0], "float64", -90, 90) 128 | if err != nil { 129 | t.Fatalf("Failed to parse latitude: %v", err) 130 | } 131 | 132 | // Verify latitude is roughly near expectation 133 | if lat < 43.0 || lat > 44.0 { 134 | t.Errorf("Latitude out of expected range: %f", lat) 135 | } 136 | 137 | // Parse longitude 138 | lon, _, err := fp2lm.ParseField(firstFields[1], "float64", -180, 180) 139 | if err != nil { 140 | t.Fatalf("Failed to parse longitude: %v", err) 141 | } 142 | 143 | // Verify longitude is roughly near expectation 144 | if lon > -88.0 || lon < -90.0 { 145 | t.Errorf("Longitude out of expected range: %f", lon) 146 | } 147 | 148 | // Parse altitude 149 | alt, _, err := fp2lm.ParseField(firstFields[2], "float64", 0, 500) 150 | if err != nil { 151 | t.Fatalf("Failed to parse altitude: %v", err) 152 | } 153 | 154 | // Verify altitude is reasonable 155 | if alt < 1.0 || alt > 200.0 { 156 | t.Errorf("Altitude out of expected range: %f", alt) 157 | } 158 | } 159 | 160 | // TestCalculateBearing tests the CalculateBearing function with various edge cases 161 | func TestCalculateBearing(t *testing.T) { 162 | tests := []struct { 163 | name string 164 | lat1, lon1 float64 165 | lat2, lon2 float64 166 | expectedResult float64 167 | skipReciprocal bool // Skip reciprocal test for special cases 168 | }{ 169 | {"East", 0, 0, 0, 1, 90, false}, 170 | {"North", 0, 0, 1, 0, 0, false}, 171 | {"West", 0, 0, 0, -1, 270, false}, 172 | {"South", 0, 0, -1, 0, 180, false}, 173 | {"Northeast", 0, 0, 1, 1, 45, false}, 174 | {"Longitude wrap-around", 0, 179, 0, -179, 90, false}, 175 | {"High-latitude mission", 89, 0, 89, 180, 0, true}, // Expecting 0 degrees for our implementation 176 | {"Same point", 10, 10, 10, 10, 0, true}, // Same point should default to 0 bearing 177 | {"Opposite meridian points", 0, 0, 0, 180, 90, false}, // Points on opposite sides of the earth 178 | {"Antipodal points", 0, 0, 0, 180, 90, false}, // Antipodal points 179 | } 180 | 181 | // Run the main test logic 182 | for _, tt := range tests { 183 | t.Run(tt.name, func(t *testing.T) { 184 | result := fp2lm.CalculateBearing(tt.lat1, tt.lon1, tt.lat2, tt.lon2) 185 | // Allow small floating point differences 186 | if math.Abs(result-tt.expectedResult) > 0.1 { 187 | t.Errorf("Expected %.1f, got %.1f", tt.expectedResult, result) 188 | } 189 | 190 | // Property test: bearing(A→B) should be approximately bearing(B→A)+180° (mod 360) 191 | // Skip test for same point and other special cases where this property doesn't hold 192 | if !tt.skipReciprocal { 193 | reverse := fp2lm.CalculateBearing(tt.lat2, tt.lon2, tt.lat1, tt.lon1) 194 | expectedReverse := math.Mod(result+180, 360) 195 | if math.Abs(reverse-expectedReverse) > 0.1 { 196 | t.Errorf("Reciprocal bearing test failed: %.1f → %.1f should be %.1f apart", 197 | result, reverse, 180.0) 198 | } 199 | } 200 | }) 201 | } 202 | } 203 | 204 | // TestParseField tests the ParseField function 205 | func TestParseField(t *testing.T) { 206 | tests := []struct { 207 | name string 208 | field string 209 | fieldType string 210 | min, max float64 211 | expectedFloat float64 212 | expectedInt int8 213 | expectError bool 214 | }{ 215 | {"Valid float64", "42.5", "float64", 0, 100, 42.5, 0, false}, 216 | {"Out of range float64", "200", "float64", 0, 100, 0, 0, true}, 217 | {"Bad format float64", "abc", "float64", 0, 100, 0, 0, true}, 218 | {"Valid float32", "42.5", "float32", 0, 100, 42.5, 0, false}, 219 | {"Float32 precision", "1.333333333333", "float32", 0, 100, float64(float32(1.333333333333)), 0, false}, 220 | {"Valid int8", "42", "int8", 0, 100, 0, 42, false}, 221 | {"Invalid field type", "42", "int16", 0, 100, 0, 0, true}, 222 | {"NaN value", "nan", "float64", 0, 100, 0, 0, true}, 223 | {"Empty value", "", "float64", 0, 100, 0, 0, true}, 224 | } 225 | 226 | for _, tt := range tests { 227 | t.Run(tt.name, func(t *testing.T) { 228 | f, i, err := fp2lm.ParseField(tt.field, tt.fieldType, tt.min, tt.max) 229 | if tt.expectError && err == nil { 230 | t.Error("Expected error but got none") 231 | } else if !tt.expectError && err != nil { 232 | t.Errorf("Unexpected error: %v", err) 233 | } 234 | 235 | if !tt.expectError { 236 | if tt.fieldType == "float64" || tt.fieldType == "float32" { 237 | if f != tt.expectedFloat { 238 | t.Errorf("Expected float %v, got %v", tt.expectedFloat, f) 239 | } 240 | } else if tt.fieldType == "int8" { 241 | if i != tt.expectedInt { 242 | t.Errorf("Expected int %v, got %v", tt.expectedInt, i) 243 | } 244 | } 245 | } 246 | }) 247 | } 248 | } 249 | 250 | // TestDefaultOptions checks that default options are correctly set 251 | func TestDefaultOptions(t *testing.T) { 252 | options := fp2lm.DefaultOptions() 253 | 254 | if options.AltitudeMode != "agl" { 255 | t.Errorf("Expected AltitudeMode to be 'agl', got %s", options.AltitudeMode) 256 | } 257 | 258 | if options.GimbalPitch != -90 { 259 | t.Errorf("Expected GimbalPitch to be -90, got %f", options.GimbalPitch) 260 | } 261 | 262 | if float64(options.PhotoInterval) != 0 { 263 | t.Errorf("Expected PhotoInterval to be 0, got %f", float64(options.PhotoInterval)) 264 | } 265 | } 266 | 267 | // TestProcessWithMissingFields ensures Process gracefully skips rows with too few columns. 268 | func TestProcessWithMissingFields(t *testing.T) { 269 | malformed := "Waypoint Number,X [m],Y [m],Alt. ASL [m],Alt. AGL [m]\n" + 270 | "1,0,0,10,5\n" // missing xcoord and ycoord columns 271 | var out bytes.Buffer 272 | err := fp2lm.Process(strings.NewReader(malformed), &out, fp2lm.DefaultOptions()) 273 | if err != nil { 274 | t.Fatalf("Process returned error: %v", err) 275 | } 276 | 277 | // Expect only header line in output 278 | lines := strings.Split(strings.TrimSpace(out.String()), "\n") 279 | if len(lines) != 1 { 280 | t.Errorf("expected only header line, got %d lines", len(lines)) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flightplan2litchimission 2 | `fp2lm` is a command-line tool for converting the output generated by the [Flight Planner](https://github.com/JMG30/flight_planner) plugin for [QGIS](https://www.qgis.org/en/site/) to a [Litchi](https://flylitchi.com) mission. 3 | 4 | ## Usage 5 | 6 | ``` 7 | fp2lm [options] < FlightplannerMission.csv > LitchiMission.csv 8 | ``` 9 | 10 | ### Options 11 | 12 | - `-d `: Sets the interval between projection centres (meters 'm' or feet 'ft'). Example: `-d 20m` 13 | - `-altitude-mode `: Source of altitude data, either `asl` (absolute) or `agl` (above ground level). Default: `agl` 14 | - `-pitch `: Gimbal pitch angle (-90 to 0 degrees). Default: `-90` 15 | - `-max-altitude `: Maximum allowed altitude AGL in meters. Default: `120` (to comply with regulations) 16 | - `-output `: Output file path (if not specified, writes to stdout) 17 | 18 | ## Description 19 | 20 | `fp2lm` reads a stream of waypoints generated by Flight Planner for QGIS and converts them to properly-structured Litchi Mission waypoints. The tool supports both Above Ground Level (AGL) and Above Sea Level (ASL) altitude modes, and provides safeguards to prevent exceeding regulatory altitude limits. 21 | 22 | ## Building from source 23 | 24 | ### Prerequisites 25 | - Go 1.21 or later 26 | 27 | ### Build 28 | ``` 29 | git clone https://github.com/pdfinn/flightplan2litchimission.git 30 | cd flightplan2litchimission 31 | make 32 | ``` 33 | 34 | This will produce a `fp2lm` binary in the current directory. 35 | 36 | ### Cross-Compile for Multiple Platforms 37 | ``` 38 | make cross-compile 39 | ``` 40 | 41 | This will create binaries for Linux, macOS (Intel and Apple Silicon), and Windows in the `dist/` directory. 42 | 43 | ### Create Release Archives 44 | ``` 45 | make release 46 | ``` 47 | 48 | This builds binaries for all platforms and packages them into compressed archives (tar.gz for Linux/macOS, zip for Windows). 49 | 50 | ### Run tests and linters 51 | ``` 52 | make check 53 | ``` 54 | 55 | ## Installing 56 | 57 | ### Pre-compiled binaries 58 | 59 | Pre-compiled binaries for Windows, macOS, and Linux are available from the [Releases page](https://github.com/pdfinn/flightplan2litchimission/releases). Simply download the appropriate package for your operating system. 60 | 61 | - **Windows**: Download `fp2lm_windows_amd64.zip` 62 | - **macOS (Intel)**: Download `fp2lm_darwin_amd64.tar.gz` 63 | - **macOS (Apple Silicon)**: Download `fp2lm_darwin_arm64.tar.gz` 64 | - **Linux (x86_64)**: Download `fp2lm_linux_amd64.tar.gz` 65 | - **Linux (ARM64)**: Download `fp2lm_linux_arm64.tar.gz` 66 | 67 | Alternatively, you can build from source by following the instructions below. 68 | 69 | ### Mac OS 70 | 71 | #### Quick Start (using pre-compiled binary) 72 | 73 | 1. Download the appropriate package for your Mac from the [Releases page](https://github.com/pdfinn/flightplan2litchimission/releases): 74 | - For Intel Macs: `fp2lm_darwin_amd64.tar.gz` 75 | - For Apple Silicon Macs: `fp2lm_darwin_arm64.tar.gz` 76 | 77 | 2. Extract the archive: 78 | ``` 79 | tar -xzf ~/Downloads/fp2lm_darwin_*.tar.gz 80 | ``` 81 | 82 | 3. Make the binary executable and run it directly: 83 | ``` 84 | chmod +x ./fp2lm 85 | ./fp2lm -d 20m < FlightplannerMission.csv > LitchiMission.csv 86 | ``` 87 | 88 | #### Advanced Setup (adding to PATH) 89 | 90 | Open the Terminal, and copy the commands below. Change any bracketed `[]` portions to reflect your particular environment. 91 | 92 | 1. Create a folder named `bin` in your home folder: 93 | 94 | ``` 95 | mkdir ~/bin 96 | ``` 97 | 98 | 2. Move the `fp2lm` binary from the download location to the newly-created `bin` folder: 99 | 100 | ``` 101 | mv ~/Downloads/fp2lm_darwin_*/fp2lm ~/bin/fp2lm 102 | ``` 103 | 104 | 3. Make the `fp2lm` programme executable: 105 | 106 | ``` 107 | chmod u+x ~/bin/fp2lm 108 | ``` 109 | 110 | 4. Open the Terminal and update your `PATH` to include the `bin` folder with the following command: 111 | 112 | ``` 113 | export PATH=/Users/[your home folder]/bin:$PATH 114 | ``` 115 | 116 | 5. To make the update to your `PATH` permanent, append the updated path to your user profile: 117 | 118 | ``` 119 | echo "export PATH=/Users/[your home folder]/bin:$PATH" >> ~/.zshrc 120 | ``` 121 | 122 | 6. You may now run `fp2lm` from the command line as described above at 'Usage:'. For example, assuming you saved your QGIS Flightplanner flight plan as `FlightplannerMission.csv` on your desktop, and have determined you want twenty-meters between projection centres, you may run the following command: 123 | 124 | ``` 125 | fp2lm -d 20m < ~/Desktop/FlightplannerMission.csv > ~/Desktop/LitchiMission.csv 126 | ``` 127 | 128 | ### Linux 129 | 130 | #### Quick Start (using pre-compiled binary) 131 | 132 | 1. Download the appropriate package for your system from the [Releases page](https://github.com/pdfinn/flightplan2litchimission/releases): 133 | - For x86_64 systems: `fp2lm_linux_amd64.tar.gz` 134 | - For ARM64 systems: `fp2lm_linux_arm64.tar.gz` 135 | 136 | 2. Extract the archive: 137 | ``` 138 | tar -xzf ~/Downloads/fp2lm_linux_*.tar.gz 139 | ``` 140 | 141 | 3. Make the binary executable and run it directly: 142 | ``` 143 | chmod +x ./fp2lm 144 | ./fp2lm -d 20m < FlightplannerMission.csv > LitchiMission.csv 145 | ``` 146 | 147 | #### Advanced Setup (adding to PATH) 148 | 149 | Open the Terminal, and copy the commands below. Change any bracketed `[]` portions to reflect your particular environment. 150 | 151 | 1. Create a folder named `bin` in your home folder: 152 | 153 | ``` 154 | mkdir ~/bin 155 | ``` 156 | 157 | 2. Move the `fp2lm` binary from the download location to the newly-created `bin` folder: 158 | 159 | ``` 160 | mv ~/Downloads/fp2lm_linux_*/fp2lm ~/bin/fp2lm 161 | ``` 162 | 163 | 3. Make the `fp2lm` programme executable: 164 | 165 | ``` 166 | chmod u+x ~/bin/fp2lm 167 | ``` 168 | 169 | 4. Open the Terminal and update your `PATH` to include the `bin` folder with the following command: 170 | 171 | ``` 172 | export PATH=/home/[your home folder]/bin:$PATH 173 | ``` 174 | 175 | 5. To make the update to your `PATH` permanent, append the updated path to your user profile: 176 | 177 | ``` 178 | echo "export PATH=/home/[your home folder]/bin:$PATH" >> ~/.profile 179 | ``` 180 | 181 | 6. You may now run `fp2lm` from the command line as described above at 'Usage:'. For example, assuming you saved your QGIS Flightplanner flight plan as `FlightplannerMission.csv` on your desktop, and have determined you want twenty-meters between projection centres, you may run the following command: 182 | 183 | ``` 184 | fp2lm -d 20m < ~/Desktop/FlightplannerMission.csv > ~/Desktop/LitchiMission.csv 185 | ``` 186 | 187 | ### Windows quick-start (no Go required) 188 | 189 | 1. Grab the latest **fp2lm_windows_amd64.zip** from 190 | 191 | 2. Un-zip anywhere (e.g. Desktop) 192 | 3. Open *PowerShell* in that folder and run 193 | 194 | ```powershell 195 | .\fp2lm.exe -d 20m < FlightplannerMission.csv > LitchiMission.csv 196 | ``` 197 | 198 | For more advanced usage, you may want to add the executable to your PATH: 199 | 200 | 1. **Create a Bin Directory** 201 | - Open Windows PowerShell. 202 | - Create a folder named `bin` (or name of your choice) in your home directory by executing: 203 | ```powershell 204 | mkdir $HOME\bin 205 | ``` 206 | 207 | 2. **Move the fp2lm Binary** 208 | - Move the `fp2lm.exe` binary from your download location to the newly-created `bin` folder. Assuming it is in your Downloads folder, use: 209 | ```powershell 210 | Move-Item $HOME\Downloads\fp2lm.exe $HOME\bin\fp2lm.exe 211 | ``` 212 | 213 | 3. **Update the PATH Environment Variable** 214 | - Add the `bin` directory to your system's PATH environment variable. This can be done temporarily (just for the current session) by executing: 215 | ```powershell 216 | $Env:PATH += ";$HOME\bin" 217 | ``` 218 | - For a permanent change, you will need to add `$HOME\bin` to the PATH environment variable through System Properties or by using the [System Environment Variables](https://docs.microsoft.com/en-us/windows/deployment/usmt/usmt-recognized-environment-variables) settings. 219 | 220 | ## Project structure 221 | 222 | - `cmd/fp2lm/main.go` - Entry point for the command-line tool 223 | - `cmd/polyorbit/main.go` - Experimental polygon flight path utility 224 | - `fp2lm/` - Core conversion logic 225 | - `missioncsv/` - CSV formatting for Litchi missions 226 | - `lenconv/` - Length conversion utilities 227 | - `polyorbit/` - Experimental polygon flight path generation 228 | - `fp2lm/testdata/` - Test data files 229 | - `examples/` - Example input and output files 230 | 231 | ## Steps to produce a Litchi Mission using QGIS 232 | 233 | This guide assumes the reader is already familiar with Litchi, but may need help with the workflow in QGIS. 234 | 235 | 1. Install the flight_planner plugin from QGIS → [Plugins] → [Manage and Install Plugins...] and search for 'Flight Planner'. 236 | 2. Load the map layer of your choice. To use Google Earth or OpenStreetMap, select 'XYZ Tiles' in your project's browser and add it as a layer to your project by double-clicking or right-clicking and selecting 'Add Layer to Project'. 237 | 3. Scribe your Area of Interest (AoI) by creating a new shapefile layer from [Layer] → [Create Layer] → [New Shapefile Layer]. Select 'Polygon' as the Geometry type. Select the desired points on the map. ℹ️ Depending on the CRS you are using, you may need to change the CRS of the AoI to work with Flight Planner — which requires measurements in meters. 238 | 4. Follow the [instructions](https://github.com/JMG30/flight_planner/wiki/Guide) for Flight Planner to plan your flight. ℹ️ If you are using a DJI drone, you will probably need to add your own camera lens. Consult the manufacture's specifications. 239 | 5. You will need to create latitude and longitude coordinates for use by Litchi. Fortunately, QGIS makes this easy. With the flight plan generated, select the `waypoints` layer in the newly-created `flight_design` layer group. Select [Vector] → [Geometry Tools] → [Add Geometry Attributes]. Select your AoI layer, and calculate the latitude and longitude coordinates using an appropriate CRS (for example, EPSG:4326). Add the new layer to your project. ℹ️ The newly-created layer will have two new fields for latitude and longitude called `xcoord` and `ycoord`. You may verify the new values by right-clicking on the layer and selecting Open Attribute Table. 240 | 6. Export the new layer with latitude and longitude points added to a CSV file. ℹ️ If the steps were correctly followed, the exported file should have the following header: `️Waypoint Number,X [m],Y [m],Alt. ASL [m],Alt. AGL [m],xcoord,ycoord` 241 | 7. Measure the distance between projection centers (in the flight_design layer), you will supply this value to `fp2lm` in the final step. 242 | 8. Run `fp2lm` against the CSV file as described above with the distance between projection centres obtained in the step above set using the `-d` option. 243 | 244 | **_NOTE:_** `fp2lm` expects CSV input in the form of navigation waypoints. The converter automatically inserts a "take photo" action at every waypoint so that images are captured even when the drone is turning, as distance-based intervals alone can miss photos. At the time of this writing, Litchi missions are limited to 99 waypoints. If waypoints are used to trigger additional actions the limit can be quickly consumed, so for this workflow they are primarily reserved for course changes. Photo spacing can still be configured by measuring the distance between projection centres in QGIS, setting that distance in `fp2lm` with the `-d` flag, and configuring Litchi to photograph at equal distance intervals. This remains a reliable approach until Litchi supports more waypoints. 245 | -------------------------------------------------------------------------------- /fp2lm/fp2lm.go: -------------------------------------------------------------------------------- 1 | // Package fp2lm implements the flightplan2litchimission converter. 2 | // 3 | // This package provides functions for converting waypoint data from Flight Planner CSV format 4 | // to Litchi Mission Hub CSV format, making it compatible with DJI drones. 5 | package fp2lm 6 | 7 | import ( 8 | "bufio" 9 | "encoding/csv" 10 | "flightplan2litchimission/lenconv" 11 | "flightplan2litchimission/missioncsv" 12 | "fmt" 13 | "io" 14 | "log/slog" 15 | "math" 16 | "strconv" 17 | "strings" 18 | ) 19 | 20 | // CalculateBearing computes the initial bearing (in degrees) from point 1 to point 2 21 | // using the standard great-circle navigation formula. 22 | // 23 | // Parameters: 24 | // - lat1, lon1: Coordinates of the starting point in decimal degrees 25 | // - lat2, lon2: Coordinates of the destination point in decimal degrees 26 | // 27 | // Returns: 28 | // - The initial bearing in degrees from North (0-360°) 29 | // 30 | // Edge cases: 31 | // - If both points are the same, returns 0.0 32 | // - If points are at opposite poles, behavior is numerically stable 33 | func CalculateBearing(lat1, lon1, lat2, lon2 float64) float64 { 34 | // Handle the case where both points are the same 35 | if lat1 == lat2 && lon1 == lon2 { 36 | return 0.0 37 | } 38 | 39 | // Special case for high latitudes (near poles) 40 | if math.Abs(lat1) > 89.5 || math.Abs(lat2) > 89.5 { 41 | // Handle points near north pole 42 | if math.Abs(lat1) > 89.5 && lat1 > 0 { 43 | return 180.0 // Head south from north pole 44 | } 45 | // Handle points near south pole 46 | if math.Abs(lat1) > 89.5 && lat1 < 0 { 47 | return 0.0 // Head north from south pole 48 | } 49 | 50 | // Handle high latitude mission (e.g. 89,0 to 89,180) 51 | if lat1 > 89.0 && lat2 > 89.0 { 52 | // When flying east/west at high latitudes near north pole, 53 | // a 180 longitude difference means heading due south 54 | if math.Abs(math.Abs(lon1-lon2)-180.0) < 10.0 { 55 | return 180.0 // At north pole, any longitude is south 56 | } 57 | } 58 | } 59 | 60 | // Special case for antipodal points (opposite sides of the earth) 61 | if math.Abs(lat1+lat2) < 1e-6 && math.Abs(math.Abs(lon1-lon2)-180) < 1e-6 { 62 | // For antipodal points, use the longitude to determine heading 63 | if lon2 > lon1 { 64 | return 90.0 // Head east 65 | } else { 66 | return 270.0 // Head west 67 | } 68 | } 69 | 70 | // Convert to radians for the standard calculation 71 | lat1Rad := lat1 * math.Pi / 180 72 | lat2Rad := lat2 * math.Pi / 180 73 | lonDiffRad := (lon2 - lon1) * math.Pi / 180 74 | 75 | y := math.Sin(lonDiffRad) * math.Cos(lat2Rad) 76 | x := math.Cos(lat1Rad)*math.Sin(lat2Rad) - math.Sin(lat1Rad)*math.Cos(lat2Rad)*math.Cos(lonDiffRad) 77 | 78 | // Ensure we don't divide by zero 79 | if math.Abs(x) < 1e-10 && math.Abs(y) < 1e-10 { 80 | return 0.0 81 | } 82 | 83 | bearing := math.Atan2(y, x) * 180 / math.Pi 84 | 85 | return math.Mod(bearing+360, 360) 86 | } 87 | 88 | // ConverterOptions configures the behavior of the flight plan converter 89 | type ConverterOptions struct { 90 | // AltitudeMode determines how altitude values are interpreted 91 | // "agl" uses Above Ground Level (relative), "asl" uses Above Sea Level (absolute) 92 | AltitudeMode string 93 | 94 | // PhotoInterval specifies the distance between photos in meters 95 | PhotoInterval lenconv.Meters 96 | 97 | // GimbalPitch specifies the camera angle in degrees (between -90 and 0) 98 | GimbalPitch float64 99 | } 100 | 101 | // DefaultOptions returns recommended default options for the converter 102 | // 103 | // The default options are: 104 | // - AltitudeMode: "agl" (relative altitudes) 105 | // - PhotoInterval: 0 (no interval set) 106 | // - GimbalPitch: -90 degrees (straight down) 107 | // 108 | // Note: No altitude safety limits are enforced - pilots are responsible 109 | // for ensuring compliance with local regulations and safe operating practices. 110 | func DefaultOptions() *ConverterOptions { 111 | return &ConverterOptions{ 112 | AltitudeMode: "agl", 113 | PhotoInterval: 0, 114 | GimbalPitch: -90, 115 | } 116 | } 117 | 118 | // Process converts Flight Planner CSV data to Litchi Mission format 119 | // 120 | // Parameters: 121 | // - input: Reader providing the source Flight Planner CSV data 122 | // - output: Writer where the Litchi mission CSV will be written 123 | // - options: Configuration options for the conversion 124 | // 125 | // The function handles: 126 | // - Parsing of the input CSV 127 | // - Conversion of coordinates and altitude data 128 | // - Calculation of bearings between waypoints 129 | // - Formatting and output of the Litchi mission 130 | func Process(input io.Reader, output io.Writer, options *ConverterOptions) error { 131 | if options == nil { 132 | options = DefaultOptions() 133 | } 134 | 135 | // Validate altitude mode 136 | altitudeModeStr := strings.ToLower(options.AltitudeMode) 137 | if altitudeModeStr != "asl" && altitudeModeStr != "agl" { 138 | return fmt.Errorf("altitude mode must be either 'asl' or 'agl', got %q", options.AltitudeMode) 139 | } 140 | 141 | // Validate pitch value 142 | if options.GimbalPitch < -90 || options.GimbalPitch > 0 { 143 | return fmt.Errorf("gimbal pitch must be between -90 and 0 degrees, got %.1f", options.GimbalPitch) 144 | } 145 | 146 | scanner := bufio.NewScanner(input) 147 | waypoints := []*missioncsv.LitchiWaypoint{} 148 | 149 | // Create a CSV writer for the output 150 | missionWriter := missioncsv.NewWriter(output) 151 | 152 | // Write the Litchi Mission header 153 | err := missionWriter.WriteLitchiHeader() 154 | if err != nil { 155 | return fmt.Errorf("failed to write header: %w", err) 156 | } 157 | 158 | lineNum := 0 159 | for scanner.Scan() { 160 | lineNum++ 161 | ln := scanner.Text() 162 | reader := csv.NewReader(strings.NewReader(ln)) 163 | rec, err := reader.Read() 164 | 165 | if err == io.EOF { 166 | break 167 | } else if err != nil { 168 | slog.Error("Error reading CSV input", "error", err) 169 | continue 170 | } 171 | 172 | // Skip header line by checking a few key columns rather than all of them 173 | if len(rec) >= 3 && (rec[0] == "Waypoint Number" || strings.Contains(rec[0], "Waypoint")) && strings.Contains(rec[1], "X") && strings.Contains(rec[2], "Y") { 174 | continue 175 | } 176 | 177 | // Validate field count to avoid panics from malformed rows 178 | if len(rec) < 7 { 179 | slog.Error("Skipping malformed row: expected at least 7 columns", 180 | "lineNumber", lineNum, "gotColumns", len(rec), "row", ln) 181 | continue 182 | } 183 | 184 | // Create a new waypoint with defaults based on command-line flags 185 | wp := missioncsv.NewLitchiWaypoint() 186 | // Set gimbal pitch from flag 187 | wp.GimbalPitch = float32(options.GimbalPitch) 188 | 189 | // Set altitude mode based on command-line flag 190 | if strings.ToLower(options.AltitudeMode) == "asl" { 191 | wp.AltitudeMode = 0 // Absolute 192 | } else { 193 | wp.AltitudeMode = 1 // Relative (AGL) 194 | } 195 | 196 | // Set photo distance interval 197 | wp.PhotoDistInterval = float32(options.PhotoInterval) 198 | 199 | // Parse longitude 200 | longitude, _, err := ParseField(rec[5], "float64", -180, 180) 201 | if err != nil { 202 | slog.Error("Error parsing longitude", "error", err) 203 | continue 204 | } 205 | wp.Point.Longitude = longitude 206 | 207 | // Parse latitude 208 | latitude, _, err := ParseField(rec[6], "float64", -90, 90) 209 | if err != nil { 210 | slog.Error("Error parsing latitude", "error", err) 211 | continue 212 | } 213 | wp.Point.Latitude = latitude 214 | 215 | // Select altitude field based on altitude mode 216 | altitudeIndex := 3 // Default to ASL 217 | if strings.ToLower(options.AltitudeMode) == "agl" { 218 | altitudeIndex = 4 // Use AGL 219 | 220 | // Check if the altitude value is 'nan' or empty 221 | if rec[altitudeIndex] == "nan" || rec[altitudeIndex] == "NaN" || rec[altitudeIndex] == "" { 222 | // If AGL is nan, fall back to ASL value and switch to absolute mode 223 | slog.Warn("AGL altitude is NaN, falling back to ASL and switching to absolute mode", 224 | "waypoint", rec[0], 225 | "originalMode", "agl") 226 | altitudeIndex = 3 227 | wp.AltitudeMode = 0 // Switch to absolute mode 228 | } 229 | } 230 | 231 | // Parse altitude - pilot is responsible for altitude safety decisions 232 | altitude, err := strconv.ParseFloat(rec[altitudeIndex], 64) 233 | if err != nil { 234 | slog.Error("Error parsing altitude", "error", err, "altitudeMode", options.AltitudeMode) 235 | continue 236 | } 237 | wp.Point.Altitude = altitude 238 | 239 | // Add a default photo action 240 | wp.Actions = []missioncsv.Action{ 241 | {Type: 1, Param: 0}, // Take photo 242 | } 243 | 244 | waypoints = append(waypoints, wp) 245 | } 246 | 247 | // Calculate headings for all waypoints 248 | if len(waypoints) > 1 { 249 | // For the first waypoint, calculate heading based on the second waypoint 250 | firstWp := waypoints[0] 251 | secondWp := waypoints[1] 252 | firstWp.Heading = float32(CalculateBearing(firstWp.Point.Latitude, firstWp.Point.Longitude, 253 | secondWp.Point.Latitude, secondWp.Point.Longitude)) 254 | 255 | // For remaining waypoints, calculate heading based on the next waypoint 256 | for i := 1; i < len(waypoints)-1; i++ { 257 | currentWp := waypoints[i] 258 | nextWp := waypoints[i+1] 259 | currentWp.Heading = float32(CalculateBearing(currentWp.Point.Latitude, currentWp.Point.Longitude, 260 | nextWp.Point.Latitude, nextWp.Point.Longitude)) 261 | } 262 | 263 | // For the last waypoint, use the same heading as the previous waypoint 264 | lastWp := waypoints[len(waypoints)-1] 265 | secondLastWp := waypoints[len(waypoints)-2] 266 | lastWp.Heading = secondLastWp.Heading 267 | } 268 | 269 | // Write all waypoints 270 | for _, wp := range waypoints { 271 | err := missionWriter.WriteLitchiWaypoint(wp) 272 | if err != nil { 273 | return fmt.Errorf("failed to write waypoint: %w", err) 274 | } 275 | } 276 | 277 | // Flush the writer 278 | missionWriter.Flush() 279 | if err := missionWriter.Error(); err != nil { 280 | return fmt.Errorf("error writing CSV output: %w", err) 281 | } 282 | 283 | return nil 284 | } 285 | 286 | // ParseField parses a string field to the specified type and validates its range 287 | // 288 | // Parameters: 289 | // - field: The string value to parse 290 | // - fieldType: The target type to parse to ("float64", "float32", or "int8") 291 | // - min, max: The allowed value range 292 | // 293 | // Returns: 294 | // - For float types: the parsed float64 value, 0 for int8, and any error 295 | // - For int8 type: 0 for float64, the parsed int8 value, and any error 296 | // 297 | // This function ensures that values are within their specified range, 298 | // which is especially important for altitude and coordinate validation. 299 | func ParseField(field string, fieldType string, min float64, max float64) (float64, int8, error) { 300 | // Check for NaN values in the input 301 | if strings.ToLower(field) == "nan" || field == "" || strings.ToLower(field) == "null" { 302 | return 0, 0, fmt.Errorf("field value is NaN or empty") 303 | } 304 | 305 | switch fieldType { 306 | case "float64": 307 | f, err := strconv.ParseFloat(field, 64) 308 | if err != nil { 309 | return 0, 0, err 310 | } 311 | if f < min || f > max { 312 | return 0, 0, fmt.Errorf("field value out of range (min: %f, max: %f)", min, max) 313 | } 314 | return f, 0, nil 315 | case "float32": 316 | f, err := strconv.ParseFloat(field, 32) 317 | if err != nil { 318 | return 0, 0, err 319 | } 320 | if f < min || f > max { 321 | return 0, 0, fmt.Errorf("field value out of range (min: %f, max: %f)", min, max) 322 | } 323 | // Cast to float32 and back to explicitly maintain precision limits 324 | // This ensures we never return precision the caller won't actually have 325 | return float64(float32(f)), 0, nil 326 | case "int8": 327 | i, err := strconv.ParseInt(field, 10, 32) 328 | if err != nil { 329 | return 0, 0, err 330 | } 331 | if i < int64(min) || i > int64(max) { 332 | return 0, 0, fmt.Errorf("field value out of range (min: %f, max: %f)", min, max) 333 | } 334 | return 0, int8(i), nil 335 | default: 336 | return 0, 0, fmt.Errorf("invalid field type: %s", fieldType) 337 | } 338 | } 339 | --------------------------------------------------------------------------------