diff --git a/.gitignore b/.gitignore index 82f9275..e242920 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +42fdr.conf +42fdr.ini + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/42fdr.conf.example b/42fdr.conf.example new file mode 100644 index 0000000..3756914 --- /dev/null +++ b/42fdr.conf.example @@ -0,0 +1,19 @@ +[Defaults] +Aircraft = Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf +OutPath = . + + +[DREFS] +sim/cockpit2/gauges/indicators/airspeed_kts_pilot = {Speed}, 1.0, IAS +sim/cockpit2/gauges/indicators/altitude_ft_pilot = {ALTMSL}, 1.0, Alt +sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot = 29.92, 1.0 + + +[Aircraft/PIPERS_1150/Piper_PA-28-161/Piper_PA-28-161(Garmin)/piper warrior.acf] +Tails = N222ND, N238ND, N239ND, N263ND, N267ND, N276ND, N291MK + + +[N263ND] +headingTrim = 0.0 +pitchTrim = 0.0 +rollTrim = 0.0 \ No newline at end of file diff --git a/42fdr.py b/42fdr.py index 9f99e97..17dd15e 100755 --- a/42fdr.py +++ b/42fdr.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -import argparse, csv, os, sys +import argparse, configparser, csv, os, re, sys +import math import xml.etree.ElementTree as ET from datetime import date, datetime, timedelta, timezone from enum import Enum from pathlib import Path -from typing import Dict, List, TextIO, Tuple, Union +from typing import Dict, List, MutableMapping, TextIO, Tuple, Union FdrColumnWidth = 19 @@ -17,20 +18,78 @@ class FileType(Enum): class Config(): + outPath:str = '.' + aircraft:str = 'Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf' + config:MutableMapping = None drefSources:Dict[str, str] = {} drefDefines:List[str] = [] - aircraft:str = 'Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf' - outPath:str = '.' def __init__(self, cliArgs:argparse.Namespace): - self.aircraft = cliArgs.aircraft - self.outPath = cliArgs.outputFolder - self.addDref('Speed', 'GndSpd', 'sim/cockpit2/gauges/indicators/ground_speed_kt', '1.0') + configFile = self.findConfigFile(cliArgs.config) + self.config = configparser.RawConfigParser() + self.config.read(configFile) + + defaults = self.config['Defaults'] if 'Defaults' in self.config else {} + if cliArgs.aircraft: + self.aircraft = cliArgs.aircraft + elif 'aircraft' in defaults: + self.aircraft = defaults['aircraft'] + if cliArgs.outputFolder: + self.outPath = cliArgs.outputFolder + elif 'outpath' in defaults: + self.outPath = defaults['outpath'] + + + self.addDref('sim/cockpit2/gauges/indicators/ground_speed_kt', '{Speed}', '1.0', 'GndSpd') + if 'DREFS' in self.config.sections(): + drefs = self.config['DREFS'] + for dref in drefs: + self.addDref(dref, *[x.strip() for x in drefs[dref].rsplit(',', 3)]) + + def addDref(self, instrument:str, value:str, scale:str='1.0', name:str=None): + name = name or instrument.rpartition('/')[2][:FdrColumnWidth] + if name not in self.drefSources: + self.drefSources[name] = value + self.drefDefines.append(f'{instrument}\t{scale}\t\t// source: {value}') + + def acftByTail(self, tailNumber:str): + for section in self.config.sections(): + if section.lower().startswith('aircraft/'): + aircraft = self.config[section] + if tailNumber in [tail.strip() for tail in aircraft['Tails'].split(',')]: + return section + + def tail(self, tailNumber:str): + for section in self.config.sections(): + if section.lower() == tailNumber.lower(): + tailSection = self.config[section] + + tailConfig = {} + for key in self.config[section]: + tailConfig[key] = numberOrString(tailSection[key]) + + if 'headingtrim' not in tailConfig: + tailConfig['headingtrim'] = 0 + if 'pitchtrim' not in tailConfig: + tailConfig['pitchtrim'] = 0 + if 'rolltrim' not in tailConfig: + tailConfig['rolltrim'] = 0 + + return tailConfig + + def findConfigFile(self, cliPath:str): + if cliPath: + return cliPath + + paths = ('.', os.path.dirname(os.path.abspath(__file__))) + files = ('42fdr.conf', '42fdr.ini') + for path in paths: + for file in files: + fullPath = os.path.join(path, file) + if Path(fullPath).is_file(): + return fullPath - def addDref(self, source:str, name:str, paramPath:str, scale:str): - if source not in self.drefSources: - self.drefSources[source] = name - self.drefDefines.append(f'{paramPath}\t{scale}\t\t// source:{source}') + return None class FdrTrackPoint(): @@ -94,9 +153,10 @@ class FlightMeta(): def main(argv:List[str]): - parser = argparse.ArgumentParser(description='Convert ForeFlight compatible track files into X-Plane compatible FDR files.') - parser.add_argument('-a', '--aircraft', default='Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf', help='Path to write X-Plane compatible FDR v4 output file') - parser.add_argument('-o', '--outputFolder', default='.', help='Path to write X-Plane compatible FDR v4 output file') + parser = argparse.ArgumentParser(description='Convert ForeFlight compatible track files into X-Plane compatible FDR files') + parser.add_argument('-a', '--aircraft', default=None, help='Path to default X-Plane aircraft') + parser.add_argument('-c', '--config', default=None, help='Path to 42fdr config file') + parser.add_argument('-o', '--outputFolder', default=None, help='Path to write X-Plane compatible FDR v4 output file') parser.add_argument('trackfile', default=None, nargs='+', help='Path to one or more ForeFlight compatible track files (CSV)') args = parser.parse_args() @@ -209,33 +269,26 @@ def parseCsvFile(config:Config, trackFile:TextIO) -> FdrFlight: fdrFlight.summary = flightSummary(flightMeta) + tailConfig = config.tail(fdrFlight.TAIL) trackCols = readCsvRow(csvReader) trackVals = readCsvRow(csvReader) while trackVals: fdrPoint = FdrTrackPoint() trackData = dict(zip(trackCols, trackVals)) - for colName in trackData: - colValue = trackData[colName] - - if colName == 'Timestamp': - fdrPoint.TIME = datetime.fromtimestamp(float(colValue)); - elif colName == 'Latitude': - fdrPoint.LAT = float(colValue) - elif colName == 'Longitude': - fdrPoint.LONG = float(colValue) - elif colName == 'Altitude': - fdrPoint.ALTMSL = float(colValue) - elif colName == 'Course': - fdrPoint.HEADING = float(colValue) - elif colName == 'Bank': - fdrPoint.ROLL = float(colValue) - elif colName == 'Pitch': - fdrPoint.PITCH = float(colValue) - else: - for source in config.drefSources: - if colName == source: - fdrPoint.drefs[source] = float(colValue) + fdrPoint.TIME = datetime.fromtimestamp(float(trackData['Timestamp'])); + fdrPoint.LAT = float(trackData['Latitude']) + fdrPoint.LONG = float(trackData['Longitude']) + fdrPoint.ALTMSL = float(trackData['Altitude']) + fdrPoint.HEADING = plusMinus180(float(trackData['Course']) + tailConfig['headingtrim']) + fdrPoint.PITCH = plusMinus180(float(trackData['Pitch']) + tailConfig['pitchtrim']) + fdrPoint.ROLL = plusMinus180(float(trackData['Bank']) + tailConfig['rolltrim']) + + for name in config.drefSources: + value = config.drefSources[name] + meta = vars(flightMeta) + point = vars(fdrPoint) + fdrPoint.drefs[name] = eval(value.format(**meta, **point, **trackData)) fdrFlight.track.append(fdrPoint) trackVals = readCsvRow(csvReader) @@ -288,7 +341,7 @@ def writeOutputFile(config:Config, fdrFile:TextIO, fdrData:FdrFlight): fdrComment("Fields below define general data for this flight."), fdrComment("ForeFlight only provides a few of the data points that X-Plane can accept.") , '\n', - f'ACFT, {config.aircraft}\n', + f'ACFT, {config.acftByTail(fdrData.TAIL) or config.aircraft}\n', f'TAIL, {fdrData.TAIL}\n', f'DATE, {toMDY(fdrData.DATE)}\n', '\n\n', @@ -300,18 +353,18 @@ def writeOutputFile(config:Config, fdrFile:TextIO, fdrData:FdrFlight): fdrComment('The remainder of this file consists of GPS/AHRS track points.'), fdrComment('The timestamps beginning each row are in the same timezone as the original file.'), '\n', - fdrColNames(config.drefSources.values()), + fdrColNames(config.drefSources.keys()), ]) for point in fdrData.track: - time = toHMS(point.TIME) + time = point.TIME.strftime('%H:%M:%S.%f') long = str.rjust(str(point.LONG), FdrColumnWidth) lat = str.rjust(str(point.LAT), FdrColumnWidth) - altMsl = str.rjust(str(point.ALTMSL), FdrColumnWidth) + altMSL = str.rjust(str(point.ALTMSL), FdrColumnWidth) heading = str.rjust(str(point.HEADING), FdrColumnWidth) pitch = str.rjust(str(point.PITCH), FdrColumnWidth) roll = str.rjust(str(point.ROLL), FdrColumnWidth) - fdrFile.write(f'{time}, {long}, {lat}, {altMsl}, {heading}, {pitch}, {roll}') + fdrFile.write(f'{time}, {long}, {lat}, {altMSL}, {heading}, {pitch}, {roll}') drefValues = [] for dref in config.drefSources: @@ -328,8 +381,8 @@ def fdrDrefs(drefDefines:List[str]): def fdrColNames(drefNames:List[str]): - names = '''COMM, degrees, degrees, ft msl, deg, deg, deg -COMM, Longitude, Latitude, AltMSL, HDG, Pitch, Roll''' + names = '''COMM, degrees, degrees, ft msl, deg, deg, deg +COMM, Longitude, Latitude, AltMSL, Heading, Pitch, Roll''' for drefName in drefNames: names += ', '+ str.rjust(drefName, FdrColumnWidth) @@ -337,6 +390,24 @@ def fdrColNames(drefNames:List[str]): return names +'\n' +def numberOrString(numeric:str): + if re.sub('^-', '', re.sub('\\.', '', numeric)).isnumeric(): + return float(numeric) + else: + return numeric + + +def plusMinus180(degrees:float): + mod = 360 if degrees >= 0 else -360 + degrees = degrees % mod + if degrees > 180: + return degrees - 360 + elif degrees < -180: + return degrees + 360 + else: + return degrees + + def getOutpath(config:Config, inPath:str, fdrData:FdrFlight): filename = os.path.basename(inPath) outPath = config.outPath or '.' diff --git a/README.md b/README.md index 4863688..e1afa02 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Just put 42fdr.py somewhere on your computer. ## Usage -`[python3] 42fdr.py [-a Aircraft] [-o outputFolder] trackFile1 [trackFile2, trackFile3, ...]` +`[python3] 42fdr.py [-c configFile ] [-a Aircraft] [-o outputFolder] trackFile1 [trackFile2, trackFile3, ...]` You should be able to run 42fdr without explicitly invoking the python interpreter. It will convert one or more files, rename it with the `.fdr` extension, and save the output to the current working directory. @@ -21,8 +21,114 @@ X-Plane requires the FDR file to specify an aircraft model for the flight and th `Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf` will be used by default. Choose a different aircraft with the `-a` parameter. +Specify a config file with the `-c` parameter. -## Examples + +## Using a config file +Config files are optional. +They can be used to avoid passing long parameters on the command line, to compute additional columns for replay, to automatically load the correct X-Plane model for a tail number, and to correct for heading/pitch/roll deviations in specific aircraft. + +42fdr.py will automatically search for a config file named either 42fdr.conf or 42fdr.ini in the directory you run 42fdr.py from and then in the folder where 42fdr.py lives. +You can specify a custom config file from the command line. + +An example config file is provided with the name `42fdr.conf.example`. +Make a copy or rename it, then edit it as needed. +One `[Defaults]` section, one `[DREFS]` section, and as many `[]` and `[]` sections as needed are supported. + + +### [Defaults] +The `Defaults` section supports two keys, `aircraft` and `outpath`, which provide defaults for when their respective command-line arguments are not provided. + + +### [DREFS] +The `DREFS` section allows you to add additional fields to the output FDR file. + +ForeFlight only provides basic position, attitude, and ground speed. This feature can be used to copy those values to additional fields `(e.g. ground speed to airspeed indicator)`, to pass constant values (e.g. `29.92`) and to compute new values `(e.g. math.cos({Course} / 180 * math.pi))`. + +Add a key for each column you would like to add using the following syntax: +``` + = , , [columnName:string] +``` +where: +``` +'s are written in python. + +You can use basic arithmetic expressions and all of the math library functions and constants. +A field reference is provided at the end of this section, after the example config file. +``` + + +### [] +`` sections allows you to map specific tail numbers to X-Plane aircraft models. +The section name should be the path to the .acf model file, beginning with the Aircraft folder (e.g. `[Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf]`) + +A single key is supported, `Tails`, which can be used to list all tail numbers which should cause this aircraft to be used in the output file `(e.g. N1234X, N5678Y)`. + + +### [\] + +\ sections allow for correction of attitude information in the flight track. +\ section names are just airplane registration numbers `(e.g. N1234X)`. +These sections supports three keys (`headingTrim`, `pitchTrim`, `rollTrim`) and allows for attitude correction. Track data is corrected by adding the appropriate trim value. + + +#### 42fdr.conf example: +``` +[Defaults] +Aircraft = Aircraft/Laminar Research/Cessna 172 SP/Cessna_172SP_G1000.acf +OutPath = . + + +[DREFS] +sim/cockpit2/gauges/indicators/airspeed_kts_pilot = {Speed}, 1.0, IAS +sim/cockpit2/gauges/indicators/altitude_ft_pilot = {ALTMSL}, 1.0, Alt +sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot = 29.92, 1.0 + + +[Aircraft/PIPERS_1150/Piper_PA-28-161/Piper_PA-28-161(Garmin)] +Tails = N222ND, N238ND, N239ND, N263ND, N267ND, N276ND, N291MK + + +[N263ND] +headingTrim = 0.0 +pitchTrim = 0.0 +rollTrim = 0.0 +``` + + +#### DREF Field Reference: +| Track (CSV) | Flight (FDR) | Flight (meta) | +|-------------|--------------|--------------------------| +| {Timestamp} | {TIME} | {Pilot} | +| {Latitude} | {LAT} | {TailNumber} | +| {Longitude} | {LONG} | {DerivedOrigin} | +| {Altitude} | {ALTMSL} | {StartLatitude} | +| {Course} | {HEADING} | {StartLongitude} | +| {Speed} | {PITCH} | {DerivedDestination} | +| {Bank} | {ROLL} | {EndLatitude} | +| {Pitch} | | {EndLongitude} | +| | | {StartTime} | +| | | {EndTime} | +| | | {TotalDuration} | +| | | {TotalDistance} | +| | | {InitialAttitudeSource} | +| | | {DeviceModel} | +| | | {DeviceModelDetailed} | +| | | {iOSVersion} | +| | | {BatteryLevel} | +| | | {BatteryState} | +| | | {GPSSource} | +| | | {MaximumVerticalError} | +| | | {MinimumVerticalError} | +| | | {AverageVerticalError} | +| | | {MaximumHorizontalError} | +| | | {MinimumHorizontalError} | +| | | {AverageHorizontalError} | +| | | {ImportedFrom} | +| | | {RouteWaypoints} | + + +## Command-line Examples `./42fdr.py tracklog-E529A53E-FBC7-4CAC-AB46-28C123A9038A.csv` @@ -37,14 +143,22 @@ This will create the file: The same as above, except the Python interpreter is called explicitly, which is needed when using Windows, and the aircraft is changed to the Lancair Evolution. This will create the file: -- `.\tracklog-E529A53E.fdr` +- `./tracklog-E529A53E.fdr` --- -`python3 C:\Users\MadReasonable\bin\42fdr.py -o /Users/MadReaonble/Desktop/ tracklog-E529A53E.csv tracklog-DC7A92F3.csv` +`python3 C:\Users\MadReasonable\bin\42fdr.py -o C:\Users\MadReaonble\Desktop\ tracklog-E529A53E.csv tracklog-DC7A92F3.csv` Convert more than one file and send the output to the desktop. The script is not in the current working directory. This will create the files: - `C:\Users\MadReasonable\bin\tracklog-E529A53E.fdr` -- `C:\Users\MadReasonable\bin\tracklog-DC7A92F3.fdr` \ No newline at end of file +- `C:\Users\MadReasonable\bin\tracklog-DC7A92F3.fdr` + +--- +`42fdr.py -c "../mycustom.ini" tracklog-E529A53E.csv` + +A custom config file is used. The FDR files output path, aircraft, and columns depend on the specific configuration. + +This will create the file: +- `/tracklog-E529A53E.fdr` \ No newline at end of file