Skip to content

Commit

Permalink
Add support for a config file (#3)
Browse files Browse the repository at this point in the history
Add support for a config file
  [Defaults] section
    Specify defaults for aircraft and output path

  [DREFS] section
    Add custom DREFs to output FDR file.  Supports references to flight and
    track data, basic arithmetic operators and math library functions
    eg. math.cos(math.pi * {Course}/180)

  [Aircraft/*] dynamic sections
    X-Plane model specific sections
    Add a comma separated list of tail numbers to automatically set the
    ACFT field in the output file to the current section (aircraft model)

  [<Tail>] dynamic sections
    Plane specific settings
    Configure heading, pitch, roll offsets to correct attitude data

Update README.md for config file support.
Rename example config file to prevent unintentional loading
Fix timestamps, allow full micro-second precision
  • Loading branch information
MadReasonable authored Jul 23, 2024
1 parent 7a32249 commit f5dc349
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 47 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
42fdr.conf
42fdr.ini

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
19 changes: 19 additions & 0 deletions 42fdr.conf.example
Original file line number Diff line number Diff line change
@@ -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
155 changes: 113 additions & 42 deletions 42fdr.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand All @@ -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:
Expand All @@ -328,15 +381,33 @@ 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)

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 '.'
Expand Down
Loading

0 comments on commit f5dc349

Please sign in to comment.