diff --git a/lib/DateTimeUtils.ts b/lib/DateTimeUtils.ts index 2d17760..a2e711a 100644 --- a/lib/DateTimeUtils.ts +++ b/lib/DateTimeUtils.ts @@ -23,6 +23,32 @@ export class DateTimeUtils { } return utcDate.toUTCString(); } + + /** + * + * @param time HHMMSS + * @returns seconds since midnight + */ + public static convertHHMMSSToTod(time: string): number{ + const h = Number(time.substring(0,2)); + const m = Number(time.substring(2,4)); + const s = Number(time.substring(4,6)); + const tod = (h*3600 )+ (m*60) + s; + return tod; + } + + /** + * + * @param time HHMMSS + * @param date MMDDYY + * @returns seconds since epoch + */ + public static convertDateTimeToEpoch(time: string, date: string):number { + //YYYY-MM-DDTHH:mm:ss.sssZ + const timestamp = `20${date.substring(4,6)}-${date.substring(0,2)}-${date.substring(2,4)}T${time.substring(0,2)}:${time.substring(2,4)}:${time.substring(4,6)}.000Z` + const millis = Date.parse(timestamp); + return millis / 1000; + } } export default {}; diff --git a/lib/plugins/Label_H1_FPN.test.ts b/lib/plugins/Label_H1_FPN.test.ts index 41b2c51..3c2d73e 100644 --- a/lib/plugins/Label_H1_FPN.test.ts +++ b/lib/plugins/Label_H1_FPN.test.ts @@ -1,7 +1,7 @@ import { MessageDecoder } from '../MessageDecoder'; import { Label_H1_FPN } from './Label_H1_FPN'; -test('decodes Label H1 Preamble FPN landing', () => { +test('Label H1 Preamble FPN decoder has right qualifiers', () => { const decoder = new MessageDecoder(); const decoderPlugin = new Label_H1_FPN(decoder); @@ -12,40 +12,44 @@ test('decodes Label H1 Preamble FPN landing', () => { labels: ['H1'], preambles: ['FPN'], }); +}); + +test('decodes Label H1 Preamble FPN landing', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_FPN(decoder); const text = 'FPN/RI:DA:KEWR:AA:KDFW:CR:EWRDFW01(17L)..SAAME.J6.HVQ.Q68.LITTR..MEEOW..FEWWW:A:SEEVR4.FEWWW:F:VECTOR..DISCO..RIVET:AP:ILS 17L.RIVET:F:TACKEC8B5'; const decodeResult = decoderPlugin.decode({ text: text }); console.log(JSON.stringify(decodeResult, null, 2)); expect(decodeResult.decoded).toBe(true); - expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.decoder.decodeLevel).toBe('full'); expect(decodeResult.decoder.name).toBe('label-h1-fpn'); expect(decodeResult.formatted.description).toBe('Flight Plan'); - expect(decodeResult.formatted.items.length).toBe(6); + expect(decodeResult.formatted.items.length).toBe(9); expect(decodeResult.formatted.items[0].label).toBe('Route Status'); expect(decodeResult.formatted.items[0].value).toBe('Route Inactive'); expect(decodeResult.formatted.items[1].label).toBe('Origin'); expect(decodeResult.formatted.items[1].value).toBe('KEWR'); expect(decodeResult.formatted.items[2].label).toBe('Destination'); expect(decodeResult.formatted.items[2].value).toBe('KDFW'); - expect(decodeResult.formatted.items[3].label).toBe('Aircraft Route'); - expect(decodeResult.formatted.items[3].value).toBe('VECTOR >> DISCO >> RIVET'); - expect(decodeResult.formatted.items[4].label).toBe('Aircraft Route'); - expect(decodeResult.formatted.items[4].value).toBe('TACKE'); - expect(decodeResult.formatted.items[5].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[5].value).toBe('0xc8b5'); + expect(decodeResult.formatted.items[3].label).toBe('Company Route'); + expect(decodeResult.formatted.items[3].value).toBe('EWRDFW01(17L): >> SAAME > J6 > HVQ > Q68 > LITTR >> MEEOW >> FEWWW'); + expect(decodeResult.formatted.items[4].label).toBe('Arrival Procedure'); + expect(decodeResult.formatted.items[4].value).toBe('SEEVR4 starting at FEWWW'); + expect(decodeResult.formatted.items[5].label).toBe('Aircraft Route'); + expect(decodeResult.formatted.items[5].value).toBe('VECTOR >> DISCO >> RIVET'); + expect(decodeResult.formatted.items[6].label).toBe('Approach Procedure'); + expect(decodeResult.formatted.items[6].value).toBe('ILS 17L starting at RIVET'); + expect(decodeResult.formatted.items[7].label).toBe('Aircraft Route'); + expect(decodeResult.formatted.items[7].value).toBe('TACKE'); + expect(decodeResult.formatted.items[8].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[8].value).toBe('0xc8b5'); }); test('decodes Label H1 Preamble FPN full flight', () => { const decoder = new MessageDecoder(); const decoderPlugin = new Label_H1_FPN(decoder); - expect(decoderPlugin.decode).toBeDefined(); - expect(decoderPlugin.name).toBe('label-h1-fpn'); - expect(decoderPlugin.qualifiers).toBeDefined(); - expect(decoderPlugin.qualifiers()).toEqual({ - labels: ['H1'], - preambles: ['FPN'], - }); // https://app.airframes.io/messages/2161768398 const text = 'FPN/FNAAL1956/RP:DA:KPHL:AA:KPHX:CR:PHLPHX61:R:27L(26O):D:PHL3:A:EAGUL6.ZUN:AP:ILS26..AIR,N40010W080490.J110.BOWRR..VLA,N39056W089097..STL,N38516W090289..GIBSN,N38430W092244..TYGER,N38410W094050..GCK,N37551W100435..DIXAN,N36169W105573..ZUN,N34579W109093293B'; @@ -53,35 +57,36 @@ test('decodes Label H1 Preamble FPN full flight', () => { console.log(JSON.stringify(decodeResult, null, 2)); expect(decodeResult.decoded).toBe(true); - expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.decoder.decodeLevel).toBe('full'); expect(decodeResult.decoder.name).toBe('label-h1-fpn'); - expect(decodeResult.raw.flight_number).toBe('AAL1956') + expect(decodeResult.raw.flight_number).toBe('AAL1956'); + expect(decodeResult.raw.company_route.waypoints).toBeUndefined(); expect(decodeResult.formatted.description).toBe('Flight Plan'); - expect(decodeResult.formatted.items.length).toBe(5); + expect(decodeResult.formatted.items.length).toBe(9); expect(decodeResult.formatted.items[0].label).toBe('Route Status'); expect(decodeResult.formatted.items[0].value).toBe('Route Planned'); expect(decodeResult.formatted.items[1].label).toBe('Origin'); expect(decodeResult.formatted.items[1].value).toBe('KPHL'); expect(decodeResult.formatted.items[2].label).toBe('Destination'); expect(decodeResult.formatted.items[2].value).toBe('KPHX'); - expect(decodeResult.formatted.items[3].label).toBe('Runway'); - expect(decodeResult.formatted.items[3].value).toBe('27L(26O)'); - expect(decodeResult.formatted.items[4].label).toBe('Message Checksum'); - expect(decodeResult.formatted.items[4].value).toBe('0x293b'); + expect(decodeResult.formatted.items[3].label).toBe('Company Route'); + expect(decodeResult.formatted.items[3].value).toBe('PHLPHX61'); + expect(decodeResult.formatted.items[4].label).toBe('Runway'); + expect(decodeResult.formatted.items[4].value).toBe('27L(26O)'); + expect(decodeResult.formatted.items[5].label).toBe('Departure Procedure'); + expect(decodeResult.formatted.items[5].value).toBe('PHL3'); + expect(decodeResult.formatted.items[6].label).toBe('Arrival Procedure'); + expect(decodeResult.formatted.items[6].value).toBe('EAGUL6 starting at ZUN'); + expect(decodeResult.formatted.items[7].label).toBe('Approach Procedure'); + expect(decodeResult.formatted.items[7].value).toBe('ILS26: >> AIR(40.01 N, 80.49 W) > J110 > BOWRR >> VLA(39.056 N, 89.097 W) >> STL(38.516 N, 90.289 W) >> GIBSN(38.43 N, 92.244 W) >> TYGER(38.41 N, 94.05 W) >> GCK(37.551 N, 100.435 W) >> DIXAN(36.169 N, 105.573 W) >> ZUN(34.579 N, 109.093 W)'); + expect(decodeResult.formatted.items[8].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[8].value).toBe('0x293b'); }); test('decodes Label H1 Preamble FPN in-flight', () => { const decoder = new MessageDecoder(); const decoderPlugin = new Label_H1_FPN(decoder); - expect(decoderPlugin.decode).toBeDefined(); - expect(decoderPlugin.name).toBe('label-h1-fpn'); - expect(decoderPlugin.qualifiers).toBeDefined(); - expect(decoderPlugin.qualifiers()).toEqual({ - labels: ['H1'], - preambles: ['FPN'], - }); - // https://app.airframes.io/messages/2161761202 const text = 'FPN/FNUAL1187/RP:DA:KSFO:AA:KPHX:F:KAYEX,N36292W120569..LOSHN,N35509W120000..BOILE,N34253W118016..BLH,N33358W114457DDFB'; const decodeResult = decoderPlugin.decode({ text: text }); @@ -105,17 +110,98 @@ test('decodes Label H1 Preamble FPN in-flight', () => { expect(decodeResult.formatted.items[4].value).toBe('0xddfb'); }); -test('decodes Label H1 Preamble FPN ', () => { + +test('decodes Label H1 Preamble FPN with WS', () => { const decoder = new MessageDecoder(); const decoderPlugin = new Label_H1_FPN(decoder); - expect(decoderPlugin.decode).toBeDefined(); - expect(decoderPlugin.name).toBe('label-h1-fpn'); - expect(decoderPlugin.qualifiers).toBeDefined(); - expect(decoderPlugin.qualifiers()).toEqual({ - labels: ['H1'], - preambles: ['FPN'], - }); + // https://app.airframes.io/messages/2372685289 + const text = 'FPN/TS140017,021724/RP:DA:EHAM:AA:KMSP..N55064W000477..N55163W001141..ERAKA..N60000W020000..N61000W030000:WS:N61000W030000,370..N61000W040000..N60000W050000..URTAK:WS:URTAK,380..LAKES:WS:LAKES,400..N57000W070000..N54300W080000..N49000W090000..DLH..COLDD:A:BAINY3:AP:ILS30L(30L)/PR4356,344,360,1060,,,13,,,30,,,P50,M40,36090,,3296,292/DTKMSP,30L,172,215117156D'; + const decodeResult = decoderPlugin.decode({ text: text }); + console.log(JSON.stringify(decodeResult, null, 2)); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.decoder.name).toBe('label-h1-fpn'); + expect(decodeResult.raw.message_timestamp).toBe(1708178417); + expect(decodeResult.formatted.description).toBe('Flight Plan'); + expect(decodeResult.formatted.items.length).toBe(6); + expect(decodeResult.formatted.items[0].label).toBe('Route Status'); + expect(decodeResult.formatted.items[0].value).toBe('Route Planned'); + expect(decodeResult.formatted.items[1].label).toBe('Origin'); + expect(decodeResult.formatted.items[1].value).toBe('EHAM'); + expect(decodeResult.formatted.items[2].label).toBe('Destination'); + expect(decodeResult.formatted.items[2].value).toBe('KMSP..N55064W000477..N55163W001141..ERAKA..N60000W020000..N61000W030000'); // FIXME - just 'KMSP' + expect(decodeResult.formatted.items[3].label).toBe('Arrival Procedure'); + expect(decodeResult.formatted.items[3].value).toBe('BAINY3'); + expect(decodeResult.formatted.items[4].label).toBe('Approach Procedure'); + expect(decodeResult.formatted.items[4].value).toBe('ILS30L(30L)/PR4356,344,360,1060,,,13,,,30,,,P50,M40,36090,,3296,292/DTKMSP,30L,172,215117'); //FIXME - just 'ILS30L' + expect(decodeResult.formatted.items[5].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[5].value).toBe('0x156d'); + expect(decodeResult.remaining.text).toBe(':WS:N61000W030000,370..N61000W040000..N60000W050000..URTAK:WS:URTAK,380..LAKES:WS:LAKES,400..N57000W070000..N54300W080000..N49000W090000..DLH..COLDD'); +}); + +test('decodes Label H1 Preamble FPN with newlines', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_FPN(decoder); + + // https://app.airframes.io/messages/2381936957 + const text = 'FPN/SN2125/FNQFA780/RI:DA:YPPH:CR:PERMEL001:AA:YMML..MEMUP,S33451E\r\n120525.Y53.WENDY0560' + const decodeResult = decoderPlugin.decode({ text: text }); + console.log(JSON.stringify(decodeResult, null, 2)); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.decoder.name).toBe('label-h1-fpn'); + expect(decodeResult.raw.flight_number).toBe('QFA780'); + expect(decodeResult.raw.serial_number).toBe('2125'); + expect(decodeResult.formatted.description).toBe('Flight Plan'); + expect(decodeResult.formatted.items.length).toBe(5); + expect(decodeResult.formatted.items[0].label).toBe('Route Status'); + expect(decodeResult.formatted.items[0].value).toBe('Route Inactive'); + expect(decodeResult.formatted.items[1].label).toBe('Origin'); + expect(decodeResult.formatted.items[1].value).toBe('YPPH'); + expect(decodeResult.formatted.items[2].label).toBe('Company Route'); + expect(decodeResult.formatted.items[2].value).toBe('PERMEL001'); + expect(decodeResult.formatted.items[3].label).toBe('Destination'); + expect(decodeResult.formatted.items[3].value).toBe('YMML..MEMUP,S33451E120525.Y53.WENDY'); //TODO - pull out route + expect(decodeResult.formatted.items[4].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[4].value).toBe('0x0560'); +}); + +test('decodes Label H1 Preamble FPN with SN and TS', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_FPN(decoder); + + // https://app.airframes.io/messages/2372685289 + const text = 'FPN/TS155631,170224/SN155631/RP:DA:PHNL:AA:KASE:D:MKK5.KOLEA:F:KOLEA,N22354W155133..CLUTS,N23002W154393.R465.CINNY,N36109W124456..OAL,N38002W117462.J58.ILC,N38150W114237..EYELO,N38455W110469..SAKES,N38500W110163.J80.DBL,N39264W106537F5E1' + const decodeResult = decoderPlugin.decode({ text: text }); + console.log(JSON.stringify(decodeResult, null, 2)); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.decoder.name).toBe('label-h1-fpn'); + expect(decodeResult.raw.message_timestamp).toBe(Number.NaN); // DDMMYY instead of MMDDYY - need to figure out how to determine + expect(decodeResult.raw.serial_number).toBe('155631'); + expect(decodeResult.formatted.description).toBe('Flight Plan'); + expect(decodeResult.formatted.items.length).toBe(6); + expect(decodeResult.formatted.items[0].label).toBe('Route Status'); + expect(decodeResult.formatted.items[0].value).toBe('Route Planned'); + expect(decodeResult.formatted.items[1].label).toBe('Origin'); + expect(decodeResult.formatted.items[1].value).toBe('PHNL'); + expect(decodeResult.formatted.items[2].label).toBe('Destination'); + expect(decodeResult.formatted.items[2].value).toBe('KASE'); + expect(decodeResult.formatted.items[3].label).toBe('Departure Procedure'); + expect(decodeResult.formatted.items[3].value).toBe('MKK5 starting at KOLEA'); + expect(decodeResult.formatted.items[4].label).toBe('Aircraft Route'); + expect(decodeResult.formatted.items[4].value).toBe('KOLEA(22.354 N, 155.133 W) >> CLUTS(23.002 N, 154.393 W) > R465 > CINNY(36.109 N, 124.456 W) >> OAL(38.002 N, 117.462 W) > J58 > ILC(38.15 N, 114.237 W) >> EYELO(38.455 N, 110.469 W) >> SAKES(38.5 N, 110.163 W) > J80 > DBL(39.264 N, 106.537 W)'); + expect(decodeResult.formatted.items[5].label).toBe('Message Checksum'); + expect(decodeResult.formatted.items[5].value).toBe('0xf5e1'); +}); + +test('decodes Label H1 Preamble FPN ', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_FPN(decoder); const text = 'FPN Bogus message'; const decodeResult = decoderPlugin.decode({ text: text }); diff --git a/lib/plugins/Label_H1_FPN.ts b/lib/plugins/Label_H1_FPN.ts index ffdbf17..cb285ad 100644 --- a/lib/plugins/Label_H1_FPN.ts +++ b/lib/plugins/Label_H1_FPN.ts @@ -1,9 +1,6 @@ -import { decode } from 'base85'; -import { DateTimeUtils } from '../DateTimeUtils'; import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; -import { CoordinateUtils } from '../utils/coordinate_utils'; -import { RouteUtils } from '../utils/route_utils'; +import { FlightPlanUtils } from '../utils/flight_plan_utils'; export class Label_H1_FPN extends DecoderPlugin { name = 'label-h1-fpn'; @@ -21,48 +18,14 @@ export class Label_H1_FPN extends DecoderPlugin { decodeResult.formatted.description = 'Flight Plan'; decodeResult.message = message; - const checksum = message.text.slice(-4); - const data = message.text.slice(0, message.text.length-4).split(':'); - - if(data[0].startsWith('FPN/')) { - let allKnownFields = parseHeader(decodeResult, data[0]); - for(let i=1; i< data.length; i+=2) { - const key = data[i]; - const value = data[i+1]; - // TODO: discuss how store commented out bits as both raw and formatted - switch(key) { - // case 'A': // Arrival Procedure (?) - // break; - case 'AA': - addArrivalAirport(decodeResult, value); - break; - // case 'CR': // Current Route (?) - // break; - // case 'D': // Departure Procedure - // break; - case 'DA': - addDepartureAirport(decodeResult, value); - break; - case 'F': // First Waypoint - addRoute(decodeResult, value); - break; - case 'R': - addDepartureRunway(decodeResult, value); - default: - if(allKnownFields) { - decodeResult.remaining.text = ''; - allKnownFields = false; - } - decodeResult.remaining.text += `:${data[i]}`; - decodeResult.decoder.decodeLevel = 'partial'; - } - } - + const msg = message.text.replace(/\n|\r/g, ""); + const checksum = msg.slice(-4); + const data = msg.slice(0, msg.length-4).split(':'); + if(data.length > 1) { + const fulllyDecoded = FlightPlanUtils.processFlightPlan(decodeResult, data) addChecksum(decodeResult, checksum); decodeResult.decoded = true; - if(allKnownFields) { - decodeResult.decoder.decodeLevel = 'full'; - } + decodeResult.decoder.decodeLevel = fulllyDecoded ? 'full' : 'partial'; } else {// Unknown if (options?.debug) { console.log(`Decoder: Unknown H1 message: ${message.text}`); @@ -77,34 +40,6 @@ export class Label_H1_FPN extends DecoderPlugin { export default {}; -function parseHeader(decodeResult: any, header: string): boolean { - let allKnownFields = true; - const fields = header.split('/'); - if(fields.length == 3) { - decodeResult.raw.flight_number = fields[1].substring(2); // Strip off 'FN' - } else if(fields.length > 3) { - decodeResult.remaining.text = fields.slice(2,-1).join('/'); - allKnownFields = false - } - decodeResult.raw.route_status = fields[fields.length - 1]; - - var text; - if(decodeResult.raw.route_status == 'RP'){ - text = 'Route Planned'; - } else if(decodeResult.raw.route_status == 'RI') { - text = 'Route Inactive'; - } else { - text = decodeResult.raw.route_status; - } - decodeResult.formatted.items.push({ - type: 'status', - code: 'ROUTE_STATUS', - label: 'Route Status', - value: text, - }); - return allKnownFields; -}; - function addChecksum(decodeResult: any, value: string) { decodeResult.raw.checksum = Number("0x"+value); decodeResult.formatted.items.push({ @@ -114,46 +49,3 @@ function addChecksum(decodeResult: any, value: string) { value: '0x' + ('0000' + decodeResult.raw.checksum.toString(16)).slice(-4), }); }; - -function addArrivalAirport(decodeResult: any, value: string) { - decodeResult.raw.arrival_icao = value; - decodeResult.formatted.items.push({ - type: 'destination', - code: 'DST', - label: 'Destination', - value: decodeResult.raw.arrival_icao, - }); -}; - -function addDepartureAirport(decodeResult: any, value: string) { - decodeResult.raw.departure_icao = value; - decodeResult.formatted.items.push({ - type: 'origin', - code: 'ORG', - label: 'Origin', - value: decodeResult.raw.departure_icao, - }); -}; - -function addDepartureRunway(decodeResult: any, value: string) { - decodeResult.raw.runway = value; - decodeResult.formatted.items.push({ - type: 'runway', - label: 'Runway', - value: decodeResult.raw.runway, - }); -}; - -function addRoute(decodeResult: any, value: string) { - const route = value.split('.'); - decodeResult.raw.route = route.map((leg)=> RouteUtils.getWaypoint(leg)); - decodeResult.formatted.items.push({ - type: 'aircraft_route', - code: 'ROUTE', - label: 'Aircraft Route', - value: RouteUtils.routeToString(decodeResult.raw.route), - }); -}; - - - diff --git a/lib/plugins/Label_H1_M1BPOS.test.ts b/lib/plugins/Label_H1_M1BPOS.test.ts new file mode 100644 index 0000000..308bfa8 --- /dev/null +++ b/lib/plugins/Label_H1_M1BPOS.test.ts @@ -0,0 +1,57 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_M1BPOS } from './Label_H1_M1BPOS'; + +test('decodes Label H1 Preamble #M1BPOS', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_M1BPOS(decoder); + + expect(decoderPlugin.decode).toBeDefined(); + expect(decoderPlugin.name).toBe('label-h1-m1bpos'); + expect(decoderPlugin.qualifiers).toBeDefined(); + expect(decoderPlugin.qualifiers()).toEqual({ + labels: ['H1'], + preambles: ['#M1BPOS'], + }); + + // https://app.airframes.io/messages/2366921571 + const text = '#M1BPOSN29510W098448,RW04,140407,188,TATAR,4,140445,ALISS,M12,246048,374K,282K,1223,133,KSAT,KELP,,70,151437,415,73/PR1223,222,240,133,,44,40,252074,M22,180,P0,P0/RI:DA:KSAT:AA:KELP..TATAR:D:ALISS6:F:ALISS..FST'; + const decodeResult = decoderPlugin.decode({ text: text }); + console.log(JSON.stringify(decodeResult, null, 2)); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('none'); //should be partial + expect(decodeResult.decoder.name).toBe('label-h1-m1bpos'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.formatted.items.length).toBe(2); + expect(decodeResult.formatted.items[0].label).toBe('Position'); + expect(decodeResult.formatted.items[0].value).toBe('29.51 N, 98.448 W'); + expect(decodeResult.formatted.items[1].label).toBe('Route'); + expect(decodeResult.formatted.items[1].value).toBe('RW04 > TATAR > 4 > ALISS > M12 > KSAT > KELP > ?'); + expect(decodeResult.remaining.text).toBe('PR1223,222,240,133,,44,40,252074,M22,180,P0,P0'); +}); + +test('decodes Label H1 Preamble #M1BPOS ', () => { + const decoder = new MessageDecoder(); + const decoderPlugin = new Label_H1_M1BPOS(decoder); + + expect(decoderPlugin.decode).toBeDefined(); + expect(decoderPlugin.name).toBe('label-h1-m1bpos'); + expect(decoderPlugin.qualifiers).toBeDefined(); + expect(decoderPlugin.qualifiers()).toEqual({ + labels: ['H1'], + preambles: ['#M1BPOS'], + }); + + const text = '#M1BPOS Bogus message'; + const decodeResult = decoderPlugin.decode({ text: text }); + console.log(JSON.stringify(decodeResult, null, 2)); + + expect(decodeResult.decoded).toBe(true); // expect false? + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.decoder.name).toBe('label-h1-m1bpos'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.formatted.items.length).toBe(1); // should be 0 + expect(decodeResult.formatted.items[0].label).toBe('Route'); + expect(decodeResult.formatted.items[0].value).toBe(''); + +}); diff --git a/lib/plugins/Label_H1_M1BPOS.ts b/lib/plugins/Label_H1_M1BPOS.ts index f84a4b2..0d3e0eb 100644 --- a/lib/plugins/Label_H1_M1BPOS.ts +++ b/lib/plugins/Label_H1_M1BPOS.ts @@ -1,6 +1,7 @@ import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { CoordinateUtils } from '../utils/coordinate_utils'; +import { RouteUtils } from '../utils/route_utils'; export class Label_H1_M1BPOS extends DecoderPlugin { // eslint-disable-line camelcase name = 'label-h1-m1bpos'; @@ -34,16 +35,16 @@ export class Label_H1_M1BPOS extends DecoderPlugin { // eslint-disable-line came }); } - let route = items.slice(1).filter((part: any) => !/^\d(.+)$/.test(part)); - route = route.map((hop: any) => hop || '?'); - decodeResult.raw.route = route; + const route = items.slice(1).filter((part: any) => !/^\d(.+)$/.test(part)); + const waypoints = route.map((hop: any) => RouteUtils.getWaypoint(hop || '?')); + decodeResult.raw.route = {waypoints: waypoints}; decodeResult.formatted.description = 'Position Report'; decodeResult.formatted.items.push({ type: 'route', label: 'Route', - value: `${route.join(' > ')}`, + value: RouteUtils.routeToString(decodeResult.raw.route), }); decodeResult.decoded = true; diff --git a/lib/plugins/Label_H1_POS.ts b/lib/plugins/Label_H1_POS.ts index 7dc86bc..e561415 100644 --- a/lib/plugins/Label_H1_POS.ts +++ b/lib/plugins/Label_H1_POS.ts @@ -1,5 +1,7 @@ +import { DateTimeUtils } from '../DateTimeUtils'; import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { Waypoint } from '../types/waypoint'; import { RouteUtils } from '../utils/route_utils'; export class Label_H1_POS extends DecoderPlugin { @@ -24,7 +26,7 @@ export class Label_H1_POS extends DecoderPlugin { const fields = data.split(','); if(fields.length==1 && data.startsWith('/RF')) { - decodeResult.raw.route = data.substring(3,data.length).split('.').map((leg: string) => {return {name: leg}}); + decodeResult.raw.route = {waypoints: data.substring(3,data.length).split('.').map((leg: string) => RouteUtils.getWaypoint(leg))}; decodeResult.formatted.items.push({ type: 'aircraft_route', code: 'ROUTE', @@ -126,16 +128,17 @@ export class Label_H1_POS extends DecoderPlugin { longitude: decodeResult.raw.longitude * (decodeResult.raw.longitude_direction === 'W' ? -1 : 1), }; + let waypoints : Waypoint[]; if(fields.length == 11) {//variant 1 - decodeResult.raw.route = [{name: fields[1] || '?,', time: convertDateTimeToEpoch(fields[2], fields[10]), timeFormat: 'epoch'}, - {name: fields[4] || '?', time: convertDateTimeToEpoch(fields[5], fields[10]), timeFormat: 'epoch'}, - {name: fields[6] || '?'}]; + waypoints = [{name: fields[1] || '?,', time: DateTimeUtils.convertDateTimeToEpoch(fields[2], fields[10]), timeFormat: 'epoch'}, + {name: fields[4] || '?', time: DateTimeUtils.convertDateTimeToEpoch(fields[5], fields[10]), timeFormat: 'epoch'}, + {name: fields[6] || '?'}] } else { - decodeResult.raw.route = [{name: fields[1] || '?,', time: convertHHMMSSToTod(fields[2]), timeFormat: 'tod'}, - {name: fields[4] || '?', time: convertHHMMSSToTod(fields[5]), timeFormat: 'tod'}, - {name: fields[6] || '?'}]; + waypoints = [{name: fields[1] || '?,', time: DateTimeUtils.convertHHMMSSToTod(fields[2]), timeFormat: 'tod'}, + {name: fields[4] || '?', time: DateTimeUtils.convertHHMMSSToTod(fields[5]), timeFormat: 'tod'}, + {name: fields[6] || '?'}]; } - + decodeResult.raw.route = {waypoints: waypoints}; decodeResult.raw.outside_air_temperature = Number(fields[7].substring(1)) * (fields[7].charAt(0) === 'M' ? -1 : 1); decodeResult.formatted.items.push({ @@ -172,24 +175,3 @@ export class Label_H1_POS extends DecoderPlugin { } export default {}; -function convertHHMMSSToTod(time: string): number{ - const h = Number(time.substring(0,2)); - const m = Number(time.substring(2,4)); - const s = Number(time.substring(4,6)); - const tod = (h*3600 )+ (m*60) + s; - return tod; -} - -/** - * - * @param time - HHMMSS - * @param date - MMDDYY - * @returns seconds since epoch - */ -function convertDateTimeToEpoch(time: string, date: string):number { - //YYYY-MM-DDTHH:mm:ss.sssZ - const timestamp = `20${date.substring(4,6)}-${date.substring(0,2)}-${date.substring(2,4)}T${time.substring(0,2)}:${time.substring(2,4)}:${time.substring(4,6)}.000Z` - const millis = Date.parse(timestamp); - return millis / 1000; -} - diff --git a/lib/types/route.ts b/lib/types/route.ts new file mode 100644 index 0000000..962a4c0 --- /dev/null +++ b/lib/types/route.ts @@ -0,0 +1,17 @@ +import { Waypoint } from "./waypoint"; + +/** + * Representation of a route + * + * Typically a list of waypoints, this can also be a named company route. + */ +export interface Route { + /** optional name. If not set, `waypoints` is required */ + name?: string, + + /** optional runway */ + runway?: string, + + /** optional list of waypoints. If not set, `name` is required */ + waypoints?: Waypoint[], +} \ No newline at end of file diff --git a/lib/utils/flight_plan_utils.ts b/lib/utils/flight_plan_utils.ts new file mode 100644 index 0000000..6fa636b --- /dev/null +++ b/lib/utils/flight_plan_utils.ts @@ -0,0 +1,185 @@ +import { DateTimeUtils } from "../DateTimeUtils"; +import { RouteUtils } from "./route_utils"; + +export class FlightPlanUtils { + /** + * Processes flight plan data + * + * Expected format is [header, key1, val1, ... keyN, valN] + * + * @param decodeResult - results + * @param data - original message split by ':' + * @returns whether all fields were processed or not + */ + public static processFlightPlan(decodeResult: any, data: string[]): boolean { + let allKnownFields = parseHeader(decodeResult, data[0]); + for (let i = 1; i < data.length; i += 2) { + const key = data[i]; + const value = data[i + 1]; + // TODO: discuss how store commented out bits as both raw and formatted + switch (key) { + case 'A': // Arrival Procedure (?) + addProcedure(decodeResult, value, 'arrival'); + break; + case 'AA': + addArrivalAirport(decodeResult, value); + break; + case 'AP': + addProcedure(decodeResult, value, 'approach'); + break; + case 'CR': + addCompanyRoute(decodeResult, value); + break; + case 'D': // Departure Procedure + addProcedure(decodeResult, value, 'departure'); + break; + case 'DA': + addDepartureAirport(decodeResult, value); + break; + case 'F': // First Waypoint + addRoute(decodeResult, value); + break; + case 'R': + addDepartureRunway(decodeResult, value); + break; + // case 'WS': // something about routes, has altitude, so current parsing won't work + // break; + default: + if (allKnownFields) { + decodeResult.remaining.text = ''; + allKnownFields = false; + } + decodeResult.remaining.text += `:${key}:${value}`; + decodeResult.decoder.decodeLevel = 'partial'; + } + } + return allKnownFields; + } +} + +function parseHeader(decodeResult: any, header: string): boolean { + let allKnownFields = true; + const fields = header.split('/'); + // fields[0] is msg type - we already know this + for(let i=1; i RouteUtils.getWaypoint(leg))}; + decodeResult.formatted.items.push({ + type: 'aircraft_route', + code: 'ROUTE', + label: 'Aircraft Route', + value: RouteUtils.routeToString(decodeResult.raw.route), + }); + }; + +function addProcedure(decodeResult: any, value: string, type: string) { + if(decodeResult.raw.procedures === undefined) { + decodeResult.raw.procedures = []; + } + const data = value.split('.'); + let waypoints; + if(data.length>1) { + waypoints = data.slice(1).map((leg)=> RouteUtils.getWaypoint(leg)); + } + const route = {name: data[0], waypoints: waypoints}; + decodeResult.raw.procedures.push({type: type, route: route}); + const procedureName = type.substring(0,1).toUpperCase() + type.slice(1); + let procedureValue = route.name; + decodeResult.formatted.items.push({ + type: `procedure`, + code: 'proc', + label: `${procedureName} Procedure`, + value: RouteUtils.routeToString(route), + }); + }; + + function addCompanyRoute(decodeResult: any, value: string) { + const segments = value.split('.'); + const parens_idx = segments[0].indexOf('('); + let name; + let runway; + if(parens_idx === -1) { + name = segments[0]; + } else { + name = segments[0].slice(0, parens_idx); + runway = segments[0].slice(parens_idx+1, segments[0].indexOf(')')); + } + let waypoints; + if(segments.length > 1) { + waypoints = segments.slice(1).map((leg) => RouteUtils.getWaypoint(leg)); + } + decodeResult.raw.company_route = { + name: name, + runway: runway, + waypoints: waypoints, + }; + decodeResult.formatted.items.push({ + type: 'company_route', + code: 'CR', + label: 'Company Route', + value: RouteUtils.routeToString(decodeResult.raw.company_route), + }); + }; \ No newline at end of file diff --git a/lib/utils/route_utils.ts b/lib/utils/route_utils.ts index 603df49..a485744 100644 --- a/lib/utils/route_utils.ts +++ b/lib/utils/route_utils.ts @@ -1,10 +1,29 @@ +import { Route } from "../types/route"; import { Waypoint } from "../types/waypoint"; import { CoordinateUtils } from "./coordinate_utils"; export class RouteUtils { - public static routeToString(route: Waypoint[]): string { - return route.map((x) => RouteUtils.waypointToString(x)).join( ' > ').replaceAll('> >', '>>'); + public static routeToString(route: Route): string { + let str = ''; + if(route.name) { + str += route.name; + } + if(route.runway) { + str += `(${route.runway})`; + } + if(str.length!==0 && route.waypoints && route.waypoints.length === 1) { + str += ' starting at ' + } + else if(str.length!==0 && route.waypoints) { + str += ': '; + } + + if(route.waypoints) { + str += RouteUtils.waypointsToString(route.waypoints); + } + + return str; } public static waypointToString(waypoint: Waypoint): string { @@ -33,11 +52,18 @@ export class RouteUtils { // move out if we want public private static timestampToString(time: number, format: 'tod' | 'epoch'): string { - const date = new Date(time * 1000); - if(format == 'tod') { + const date = new Date(time * 1000); if(format == 'tod') { return date.toISOString().slice(11, 19); } //strip off millis return date.toISOString().slice(0,-5)+"Z"; } + + private static waypointsToString(waypoints: Waypoint[]): string { + let str = waypoints.map((x) => RouteUtils.waypointToString(x)).join( ' > ').replaceAll('> >', '>>'); + if(str.startsWith(' > ')) { + str = '>>' + str.slice(2); + } + return str; + } } \ No newline at end of file