From cc0b2e86f9d5d96e4802534e69c352845e1dbefb Mon Sep 17 00:00:00 2001 From: James Warne Date: Wed, 11 Mar 2026 11:56:41 +1100 Subject: [PATCH 1/6] Add ICAO Flight Plan parser and OFP plugin New utility icao_fpl_utils.ts parses standard (FPL-...) blocks per ICAO Doc 4444 format, extracting callsign, flight rules, aircraft type, wake turbulence category, equipment/surveillance codes, departure/destination, cruise speed/level, route, alternates, and field 18 other info (PBN, DOF, REG, EET, SEL, CODE, RMK, etc.). New Label_H1_OFP plugin detects messages containing (FPL- on H1/1M labels and uses the parser to populate DecodeResult with structured flight plan data. This unlocks decoding for all airline OFP variants (Air China, United, Air Transat, Etihad, etc.) that contain an ICAO flight plan block. 10 new tests across 2 test files (382 total passing). Co-Authored-By: Claude Opus 4.6 --- lib/MessageDecoder.ts | 1 + lib/plugins/Label_H1_OFP.test.ts | 108 ++++++++++++++++++ lib/plugins/Label_H1_OFP.ts | 98 ++++++++++++++++ lib/plugins/official.ts | 1 + lib/utils/icao_fpl_utils.test.ts | 87 ++++++++++++++ lib/utils/icao_fpl_utils.ts | 187 +++++++++++++++++++++++++++++++ 6 files changed, 482 insertions(+) create mode 100644 lib/plugins/Label_H1_OFP.test.ts create mode 100644 lib/plugins/Label_H1_OFP.ts create mode 100644 lib/utils/icao_fpl_utils.test.ts create mode 100644 lib/utils/icao_fpl_utils.ts diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index 3e2e0aec..1ade8230 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -64,6 +64,7 @@ export class MessageDecoder { this.registerPlugin(new Plugins.Label_H1_OHMA(this)); this.registerPlugin(new Plugins.Label_H1_Paren(this)); this.registerPlugin(new Plugins.Label_H1_WRN(this)); + this.registerPlugin(new Plugins.Label_H1_OFP(this)); this.registerPlugin(new Plugins.Label_H1(this)); this.registerPlugin(new Plugins.Label_H1_StarPOS(this)); this.registerPlugin(new Plugins.Label_HX(this)); diff --git a/lib/plugins/Label_H1_OFP.test.ts b/lib/plugins/Label_H1_OFP.test.ts new file mode 100644 index 00000000..19465f1c --- /dev/null +++ b/lib/plugins/Label_H1_OFP.test.ts @@ -0,0 +1,108 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_OFP } from './Label_H1_OFP'; + +describe('Label H1 OFP (ICAO Flight Plan)', () => { + let plugin: Label_H1_OFP; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_H1_OFP(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-h1-ofp'); + expect(plugin.qualifiers).toBeDefined(); + expect(plugin.qualifiers()).toEqual({ + labels: ['H1', '1M'], + }); + }); + + test('decodes multi-line FPL message', () => { + const message = { + label: 'H1', + text: `(FPL-CCA769-IS +-B77W/H-SDE3FGHIJ1J2J3J4J5M1P2RWXYZ/LB1D1 +-VESPA2354 +-N0482F350 DCT ENI DCT OAK DCT BURGL IRNMN2 +-KLAX0117 KSFO +-PBN/A1B1C1D1L1O2S2T1 NAV/ABAS RNP2 DAT/1FANSE SUR/RSP180 + DOF/260310 REG/B2036 + EET/KZLA0051 + SEL/BRCE CODE/780970 + RMK/ACAS II)`, + }; + + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.decoder.name).toBe('label-h1-ofp'); + expect(decodeResult.formatted.description).toBe('Operational Flight Plan'); + expect(decodeResult.message).toBe(message); + + expect(decodeResult.raw.callsign).toBe('CCA769'); + expect(decodeResult.raw.departure_icao).toBe('VESPA'); + expect(decodeResult.raw.arrival_icao).toBe('KLAX'); + expect(decodeResult.raw.alternate_icao).toBe('KSFO'); + expect(decodeResult.raw.tail).toBe('B2036'); + expect(decodeResult.raw.flight_plan).toBe('DCT ENI DCT OAK DCT BURGL IRNMN2'); + + // Check formatted items + const items = decodeResult.formatted.items; + expect(items.find((i) => i.code === 'CALLSIGN')!.value).toBe('CCA769'); + expect(items.find((i) => i.code === 'ACFT')!.value).toBe('B77W'); + expect(items.find((i) => i.code === 'ORG')!.value).toBe('VESPA'); + expect(items.find((i) => i.code === 'DST')!.value).toBe('KLAX'); + expect(items.find((i) => i.code === 'ALT_DST')!.value).toBe('KSFO'); + expect(items.find((i) => i.code === 'FPN')!.value).toBe( + 'DCT ENI DCT OAK DCT BURGL IRNMN2', + ); + expect(items.find((i) => i.code === 'TAIL')!.value).toBe('B2036'); + expect(items.find((i) => i.code === 'RULES')!.value).toBe('IFR'); + expect(items.find((i) => i.code === 'CRUISE')!.value).toBe('N0482 F350'); + }); + + test('decodes single-line FPL message', () => { + const message = { + label: 'H1', + text: '(FPL-UAL123-IS-B789/H-SDE2E3FGHIJ1J5M1RWXYZ/LB1D1-KSFO0100-N0487F370 DCT PORTE J584 ENI DCT-KLAX0055 KONT-PBN/A1B1C1D1L1 DOF/260311 REG/N22992)', + }; + + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.callsign).toBe('UAL123'); + expect(decodeResult.raw.departure_icao).toBe('KSFO'); + expect(decodeResult.raw.arrival_icao).toBe('KLAX'); + expect(decodeResult.raw.alternate_icao).toBe('KONT'); + expect(decodeResult.raw.tail).toBe('N22992'); + expect(decodeResult.raw.flight_plan).toBe('DCT PORTE J584 ENI DCT'); + }); + + test('does not decode message without FPL block', () => { + const message = { + label: 'H1', + text: 'FPN/RI:DA:KSFO:AA:KLAX:F:KSFO..KLAX', + }; + + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.remaining.text).toBe(message.text); + }); + + test('decodes with label 1M', () => { + const message = { + label: '1M', + text: '(FPL-UAL123-IS-B789/H-SDE2E3FGHIJ1J5M1RWXYZ/LB1D1-KSFO0100-N0487F370 DCT PORTE J584 ENI DCT-KLAX0055 KONT-PBN/A1B1C1D1L1 DOF/260311 REG/N22992)', + }; + + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.callsign).toBe('UAL123'); + }); +}); diff --git a/lib/plugins/Label_H1_OFP.ts b/lib/plugins/Label_H1_OFP.ts new file mode 100644 index 00000000..1bd4f09e --- /dev/null +++ b/lib/plugins/Label_H1_OFP.ts @@ -0,0 +1,98 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; +import { parseIcaoFpl } from '../utils/icao_fpl_utils'; + +export class Label_H1_OFP extends DecoderPlugin { + name = 'label-h1-ofp'; + + qualifiers() { + return { + labels: ['H1', '1M'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + const decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.message = message; + decodeResult.formatted.description = 'Operational Flight Plan'; + + if (!message.text.includes('(FPL-')) { + if (options.debug) { + console.log(`Decoder: No FPL block found in message`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + const fpl = parseIcaoFpl(message.text); + if (!fpl) { + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + // Populate decoded results + ResultFormatter.callsign(decodeResult, fpl.callsign); + + decodeResult.formatted.items.push({ + type: 'aircraft_type', + code: 'ACFT', + label: 'Aircraft Type', + value: fpl.aircraftType, + }); + + if (fpl.departure) { + ResultFormatter.departureAirport(decodeResult, fpl.departure); + } + + if (fpl.destination) { + ResultFormatter.arrivalAirport(decodeResult, fpl.destination); + } + + if (fpl.alternates.length > 0) { + ResultFormatter.alternateAirport(decodeResult, fpl.alternates[0]); + } + + if (fpl.route) { + ResultFormatter.flightPlan(decodeResult, fpl.route); + } + + if (fpl.otherInfo.REG) { + ResultFormatter.tail(decodeResult, fpl.otherInfo.REG); + } + + const rulesMap: Record = { + I: 'IFR', + V: 'VFR', + Y: 'IFR/VFR', + Z: 'VFR/IFR', + }; + const rulesDesc = rulesMap[fpl.flightRules] || fpl.flightRules; + decodeResult.formatted.items.push({ + type: 'flight_rules', + code: 'RULES', + label: 'Flight Rules', + value: rulesDesc, + }); + + decodeResult.formatted.items.push({ + type: 'cruise', + code: 'CRUISE', + label: 'Cruise Speed/Level', + value: `${fpl.cruiseSpeed} ${fpl.cruiseLevel}`, + }); + + // Store raw FPL data + decodeResult.raw.icao_fpl = fpl; + + decodeResult.decoded = true; + decodeResult.decoder.decodeLevel = 'full'; + + return decodeResult; + } +} diff --git a/lib/plugins/official.ts b/lib/plugins/official.ts index 23ff69e8..728ef908 100644 --- a/lib/plugins/official.ts +++ b/lib/plugins/official.ts @@ -47,6 +47,7 @@ export * from './Label_8E'; export * from './Label_B6'; export * from './Label_ColonComma'; export * from './Label_H1'; +export * from './Label_H1_OFP'; export * from './Label_H2_02E'; export * from './Label_H1_FLR'; export * from './Label_H1_OHMA'; diff --git a/lib/utils/icao_fpl_utils.test.ts b/lib/utils/icao_fpl_utils.test.ts new file mode 100644 index 00000000..052d8013 --- /dev/null +++ b/lib/utils/icao_fpl_utils.test.ts @@ -0,0 +1,87 @@ +import { parseIcaoFpl } from './icao_fpl_utils'; + +describe('ICAO FPL Parser', () => { + test('parses multi-line FPL block', () => { + const text = `(FPL-CCA769-IS +-B77W/H-SDE3FGHIJ1J2J3J4J5M1P2RWXYZ/LB1D1 +-VESPA2354 +-N0482F350 DCT ENI DCT OAK DCT BURGL IRNMN2 +-KLAX0117 KSFO +-PBN/A1B1C1D1L1O2S2T1 NAV/ABAS RNP2 DAT/1FANSE SUR/RSP180 + DOF/260310 REG/B2036 + EET/KZLA0051 + SEL/BRCE CODE/780970 + RMK/ACAS II)`; + + const result = parseIcaoFpl(text); + + expect(result).not.toBeNull(); + expect(result!.callsign).toBe('CCA769'); + expect(result!.flightRules).toBe('I'); + expect(result!.flightType).toBe('S'); + expect(result!.aircraftType).toBe('B77W'); + expect(result!.wakeTurbulence).toBe('H'); + expect(result!.equipment).toBe('SDE3FGHIJ1J2J3J4J5M1P2RWXYZ'); + expect(result!.surveillance).toBe('LB1D1'); + expect(result!.departure).toBe('VESPA'); + expect(result!.departureTime).toBe('2354'); + expect(result!.cruiseSpeed).toBe('N0482'); + expect(result!.cruiseLevel).toBe('F350'); + expect(result!.route).toBe('DCT ENI DCT OAK DCT BURGL IRNMN2'); + expect(result!.destination).toBe('KLAX'); + expect(result!.eet).toBe('0117'); + expect(result!.alternates).toEqual(['KSFO']); + expect(result!.otherInfo.PBN).toBe('A1B1C1D1L1O2S2T1'); + expect(result!.otherInfo.NAV).toBe('ABAS RNP2'); + expect(result!.otherInfo.DAT).toBe('1FANSE'); + expect(result!.otherInfo.SUR).toBe('RSP180'); + expect(result!.otherInfo.DOF).toBe('260310'); + expect(result!.otherInfo.REG).toBe('B2036'); + expect(result!.otherInfo.EET).toBe('KZLA0051'); + expect(result!.otherInfo.SEL).toBe('BRCE'); + expect(result!.otherInfo.CODE).toBe('780970'); + expect(result!.otherInfo.RMK).toBe('ACAS II'); + }); + + test('parses single-line FPL block', () => { + const text = + '(FPL-UAL123-IS-B789/H-SDE2E3FGHIJ1J5M1RWXYZ/LB1D1-KSFO0100-N0487F370 DCT PORTE J584 ENI DCT-KLAX0055 KONT-PBN/A1B1C1D1L1 DOF/260311 REG/N22992)'; + + const result = parseIcaoFpl(text); + + expect(result).not.toBeNull(); + expect(result!.callsign).toBe('UAL123'); + expect(result!.flightRules).toBe('I'); + expect(result!.flightType).toBe('S'); + expect(result!.aircraftType).toBe('B789'); + expect(result!.wakeTurbulence).toBe('H'); + expect(result!.equipment).toBe('SDE2E3FGHIJ1J5M1RWXYZ'); + expect(result!.surveillance).toBe('LB1D1'); + expect(result!.departure).toBe('KSFO'); + expect(result!.departureTime).toBe('0100'); + expect(result!.cruiseSpeed).toBe('N0487'); + expect(result!.cruiseLevel).toBe('F370'); + expect(result!.route).toBe('DCT PORTE J584 ENI DCT'); + expect(result!.destination).toBe('KLAX'); + expect(result!.eet).toBe('0055'); + expect(result!.alternates).toEqual(['KONT']); + expect(result!.otherInfo.PBN).toBe('A1B1C1D1L1'); + expect(result!.otherInfo.DOF).toBe('260311'); + expect(result!.otherInfo.REG).toBe('N22992'); + }); + + test('returns null for text without FPL block', () => { + const result = parseIcaoFpl('some random acars message text'); + expect(result).toBeNull(); + }); + + test('returns null for malformed FPL block', () => { + const result = parseIcaoFpl('(FPL-ABC)'); + expect(result).toBeNull(); + }); + + test('returns null for missing closing paren', () => { + const result = parseIcaoFpl('(FPL-CCA769-IS-B77W/H-EQUIP/SURV-KSFO0100-N0482F350 DCT'); + expect(result).toBeNull(); + }); +}); diff --git a/lib/utils/icao_fpl_utils.ts b/lib/utils/icao_fpl_utils.ts new file mode 100644 index 00000000..8b25cc3a --- /dev/null +++ b/lib/utils/icao_fpl_utils.ts @@ -0,0 +1,187 @@ +/** + * ICAO Doc 4444 Flight Plan (FPL) parser utility. + * + * Parses the standard ICAO FPL format: + * (FPL-callsign-flightRulesType + * -aircraftType/wake-equipment/surveillance + * -departureHHMM + * -speedLevel route + * -destinationHHMM alternate1 alternate2 + * -otherInfo) + */ + +export interface IcaoFlightPlan { + callsign: string; + flightRules: string; + flightType: string; + aircraftType: string; + wakeTurbulence: string; + equipment: string; + surveillance: string; + departure: string; + departureTime: string; + cruiseSpeed: string; + cruiseLevel: string; + route: string; + destination: string; + eet: string; + alternates: string[]; + otherInfo: Record; +} + +export function parseIcaoFpl(text: string): IcaoFlightPlan | null { + // Find the (FPL-...) block + const fplStart = text.indexOf('(FPL-'); + if (fplStart === -1) { + return null; + } + + // Find matching closing paren + const fplEnd = text.indexOf(')', fplStart); + if (fplEnd === -1) { + return null; + } + + // Extract inner content after "(FPL-" + const inner = text.substring(fplStart + 5, fplEnd); + + // Normalize: collapse all whitespace (newlines, multiple spaces) to single space + const normalized = inner.replace(/\s+/g, ' ').trim(); + + // Split on " -" or leading "-" to get the field sections. + // The format is: callsign-rulesType-acft/wake-equip/surv-deptHHMM-speedLevel route-destHHMM alts-other + // After removing "(FPL-", the first field is callsign-rulesType. + // Subsequent fields are separated by "-" that appears after a space or at start of a section line. + // However, the "-" delimiter is tricky: it separates major sections but also appears + // within equipment strings (e.g. SDE3FGHIJ1...) — those are NOT delimiters. + // + // The ICAO FPL has exactly 6 major sections after the callsign section, delimited by "-". + // We need to split carefully. The format after "(FPL-" is: + // section1-section2-section3-section4-section5-section6 + // where section1 = "callsign-rulesType" (contains one internal "-") + // + // Strategy: split on "-" and reconstruct based on known patterns. + + const parts = normalized.split('-'); + + // We need at least 7 parts (callsign, rulesType, acftType/wake, equip/surv, dept, speed+route, dest+alts, other...) + // But the equipment section may contain hyphens too, so we parse positionally. + + if (parts.length < 7) { + return null; + } + + // Section 1: callsign + const callsign = parts[0].trim(); + if (!callsign) { + return null; + } + + // Section 2: flight rules + flight type (e.g., "IS") + const rulesType = parts[1].trim(); + if (rulesType.length < 2) { + return null; + } + const flightRules = rulesType[0]; + const flightType = rulesType[1]; + + // Now we need to find the remaining sections. The challenge is that equipment strings + // can be long but don't contain "-". The aircraft/wake section is "TYPE/WAKE". + // The equipment/surveillance section is "EQUIP/SURV". + // These are separated by "-". + + // Section 3: aircraft type / wake turbulence category (e.g., "B77W/H") + const acftWake = parts[2].trim(); + const acftSlash = acftWake.indexOf('/'); + if (acftSlash === -1) { + return null; + } + const aircraftType = acftWake.substring(0, acftSlash); + const wakeTurbulence = acftWake.substring(acftSlash + 1); + + // Section 4: equipment / surveillance (e.g., "SDE3FGHIJ1J2J3J4J5M1P2RWXYZ/LB1D1") + const equipSurv = parts[3].trim(); + const equipSlash = equipSurv.indexOf('/'); + if (equipSlash === -1) { + return null; + } + const equipment = equipSurv.substring(0, equipSlash); + const surveillance = equipSurv.substring(equipSlash + 1); + + // Section 5: departure aerodrome + time (e.g., "VESPA2354" or "KSFO0100") + const deptField = parts[4].trim(); + // Time is always last 4 digits + const departureTime = deptField.substring(deptField.length - 4); + const departure = deptField.substring(0, deptField.length - 4); + + // Section 6: cruise speed/level + route (e.g., "N0482F350 DCT ENI DCT OAK DCT BURGL IRNMN2") + const speedRouteField = parts[5].trim(); + // Speed is first token like N0482 or M084, level follows like F350 or S1190 + const speedMatch = speedRouteField.match(/^([NKM]\d{3,4})([FAVMS]\d{3,4})\s*(.*)/); + if (!speedMatch) { + return null; + } + const cruiseSpeed = speedMatch[1]; + const cruiseLevel = speedMatch[2]; + const route = speedMatch[3] || ''; + + // Section 7: destination + EET + alternates (e.g., "KLAX0117 KSFO") + const destField = parts[6].trim(); + const destParts = destField.split(/\s+/); + const destTime = destParts[0]; + // Destination ICAO is first 4 chars, EET is last 4 digits + const destination = destTime.substring(0, destTime.length - 4); + const eet = destTime.substring(destTime.length - 4); + const alternates = destParts.slice(1).filter((s) => s.length > 0); + + // Section 8+: other information (remaining parts joined, then parsed as key/value) + const otherRaw = parts.slice(7).join('-').trim(); + const otherInfo: Record = {}; + + if (otherRaw.length > 0) { + // Other info is a series of KEY/VALUE pairs separated by spaces + // e.g., "PBN/A1B1C1D1L1O2S2T1 NAV/ABAS RNP2 DAT/1FANSE SUR/RSP180 DOF/260310 REG/B2036" + // Parse by finding KEY/ patterns + const otherTokens = otherRaw.split(/\s+/); + let currentKey = ''; + let currentValue = ''; + + for (const token of otherTokens) { + const kvMatch = token.match(/^([A-Z]{2,})\/(.*)/); + if (kvMatch) { + // Save previous key/value if exists + if (currentKey) { + otherInfo[currentKey] = currentValue.trim(); + } + currentKey = kvMatch[1]; + currentValue = kvMatch[2]; + } else if (currentKey) { + // Continuation of previous value + currentValue += ' ' + token; + } + } + // Save last key/value + if (currentKey) { + otherInfo[currentKey] = currentValue.trim(); + } + } + + return { + callsign, + flightRules, + flightType, + aircraftType, + wakeTurbulence, + equipment, + surveillance, + departure, + departureTime, + cruiseSpeed, + cruiseLevel, + route, + destination, + eet, + alternates, + otherInfo, + }; +} From 0b63f5eb76bc0dccf52cef9a31b20ef859bb0ca2 Mon Sep 17 00:00:00 2001 From: James Warne Date: Wed, 11 Mar 2026 11:52:56 +1100 Subject: [PATCH 2/6] Add easy-win plugins: M-Series position, EZF load sheet, ATIS subscription Three new decoder plugins covering spec Families 16, 17, and 20: - Label_H1_M_POS: Decodes M-series periodic position reports (e.g., Qantas SATCOM positions) with origin/dest ICAO, lat/lon, altitude, heading - Label_H1_EZF: Decodes EZF load sheet messages with passenger counts, weights (ZFW, TOW, DOW), cargo, baggage, and fuel data - Label_H1_ATIS: Decodes ATIS subscription requests extracting flight number, facility ICAO, subscription code, and airport 19 new tests across 3 test files (372 total passing). Co-Authored-By: Claude Opus 4.6 --- lib/MessageDecoder.ts | 3 + lib/plugins/Label_H1_ATIS.test.ts | 90 +++++++++++++++++++++++ lib/plugins/Label_H1_ATIS.ts | 73 +++++++++++++++++++ lib/plugins/Label_H1_EZF.test.ts | 84 ++++++++++++++++++++++ lib/plugins/Label_H1_EZF.ts | 110 +++++++++++++++++++++++++++++ lib/plugins/Label_H1_M_POS.test.ts | 90 +++++++++++++++++++++++ lib/plugins/Label_H1_M_POS.ts | 84 ++++++++++++++++++++++ lib/plugins/official.ts | 3 + 8 files changed, 537 insertions(+) create mode 100644 lib/plugins/Label_H1_ATIS.test.ts create mode 100644 lib/plugins/Label_H1_ATIS.ts create mode 100644 lib/plugins/Label_H1_EZF.test.ts create mode 100644 lib/plugins/Label_H1_EZF.ts create mode 100644 lib/plugins/Label_H1_M_POS.test.ts create mode 100644 lib/plugins/Label_H1_M_POS.ts diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index 1ade8230..0636ca9a 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -60,7 +60,10 @@ export class MessageDecoder { this.registerPlugin(new Plugins.Label_4T_ETA(this)); this.registerPlugin(new Plugins.Label_B6_Forwardslash(this)); this.registerPlugin(new Plugins.Label_H2_02E(this)); + this.registerPlugin(new Plugins.Label_H1_ATIS(this)); + this.registerPlugin(new Plugins.Label_H1_EZF(this)); this.registerPlugin(new Plugins.Label_H1_FLR(this)); + this.registerPlugin(new Plugins.Label_H1_M_POS(this)); this.registerPlugin(new Plugins.Label_H1_OHMA(this)); this.registerPlugin(new Plugins.Label_H1_Paren(this)); this.registerPlugin(new Plugins.Label_H1_WRN(this)); diff --git a/lib/plugins/Label_H1_ATIS.test.ts b/lib/plugins/Label_H1_ATIS.test.ts new file mode 100644 index 00000000..8fad9f46 --- /dev/null +++ b/lib/plugins/Label_H1_ATIS.test.ts @@ -0,0 +1,90 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_ATIS } from './Label_H1_ATIS'; + +describe('Label H1 ATIS Subscription', () => { + let plugin: Label_H1_ATIS; + const message = { label: 'H1', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_H1_ATIS(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-h1-atis'); + expect(plugin.qualifiers()).toEqual({ + labels: ['H1', '5Z'], + preambles: ['L'], + }); + }); + + test('decodes KSFO ATIS subscription', () => { + message.text = 'L95AQF0073/KSFO.TI2/030KSFOAFF5C'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.decoder.name).toBe('label-h1-atis'); + expect(decodeResult.formatted.description).toBe('ATIS Subscription'); + expect(decodeResult.message).toBe(message); + expect(decodeResult.raw.flight_number).toBe('QF0073'); + expect(decodeResult.raw.arrival_icao).toBe('KSFO'); + expect(decodeResult.raw.atis_code).toBe('030'); + expect(decodeResult.raw.checksum).toBe(0xAFF5C); + }); + + test('decodes OERK ATIS subscription', () => { + message.text = 'L01AMC5477/OERK.TI2/024OERKC8781'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.flight_number).toBe('MC5477'); + expect(decodeResult.raw.arrival_icao).toBe('OERK'); + expect(decodeResult.raw.atis_code).toBe('024'); + expect(decodeResult.raw.checksum).toBe(0xC8781); + }); + + test('decodes LICC ATIS subscription', () => { + message.text = 'L11AMC5477/LICC.TI2/024LICCAFBD9'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.flight_number).toBe('MC5477'); + expect(decodeResult.raw.arrival_icao).toBe('LICC'); + expect(decodeResult.raw.atis_code).toBe('024'); + expect(decodeResult.raw.checksum).toBe(0xAFBD9); + }); + + test('decodes with label 5Z', () => { + const msg5Z = { + label: '5Z', + text: 'L95AQF0073/KSFO.TI2/030KSFOAFF5C', + }; + const decodeResult = plugin.decode(msg5Z); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.flight_number).toBe('QF0073'); + expect(decodeResult.raw.arrival_icao).toBe('KSFO'); + }); + + test('rejects invalid message', () => { + message.text = 'LINVALID MESSAGE'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.decoder.name).toBe('label-h1-atis'); + expect(decodeResult.remaining.text).toBe(message.text); + }); + + test('rejects non-ATIS message', () => { + message.text = 'FLR/FR24030411230034583106FWC2'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); +}); diff --git a/lib/plugins/Label_H1_ATIS.ts b/lib/plugins/Label_H1_ATIS.ts new file mode 100644 index 00000000..16d114f1 --- /dev/null +++ b/lib/plugins/Label_H1_ATIS.ts @@ -0,0 +1,73 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; +import { DateTimeUtils } from '../DateTimeUtils'; + +export class Label_H1_ATIS extends DecoderPlugin { + name = 'label-h1-atis'; + + qualifiers() { + return { + labels: ['H1', '5Z'], + preambles: ['L'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + let decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.formatted.description = 'ATIS Subscription'; + decodeResult.message = message; + + // Pattern: L[2-digit seq]A[flight]/[facility].TI2/[code][airport][checksum] + const regex = /^L(\d{2})A([A-Z0-9]+)\/([A-Z]{4})\.TI2\/(\d{3})([A-Z]{4})([A-F0-9]+)$/; + const match = message.text.match(regex); + + if (!match) { + if (options.debug) { + console.log(`Decoder: Unknown H1 ATIS message: ${message.text}`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + const seq = match[1]; + const flight = match[2]; + const facility = match[3]; + const code = match[4]; + const airport = match[5]; + const checksum = match[6]; + + ResultFormatter.flightNumber(decodeResult, flight); + ResultFormatter.arrivalAirport(decodeResult, facility); + + decodeResult.raw.atis_code = code; + decodeResult.formatted.items.push({ + type: 'atis', + code: 'ATIS_CODE', + label: 'ATIS Code', + value: code, + }); + + // The airport in the TI2 section + if (airport !== facility) { + decodeResult.raw.atis_airport = airport; + decodeResult.formatted.items.push({ + type: 'icao', + code: 'ATIS_ARPT', + label: 'ATIS Airport', + value: airport, + }); + } + + // Checksum + ResultFormatter.checksum(decodeResult, parseInt(checksum, 16)); + + decodeResult.decoded = true; + decodeResult.decoder.decodeLevel = 'full'; + + return decodeResult; + } +} diff --git a/lib/plugins/Label_H1_EZF.test.ts b/lib/plugins/Label_H1_EZF.test.ts new file mode 100644 index 00000000..dc07652c --- /dev/null +++ b/lib/plugins/Label_H1_EZF.test.ts @@ -0,0 +1,84 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_EZF } from './Label_H1_EZF'; + +describe('Label H1 Preamble EZF Load Sheet', () => { + let plugin: Label_H1_EZF; + const message = { label: 'H1', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_H1_EZF(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-h1-ezf'); + expect(plugin.qualifiers()).toEqual({ + labels: ['H1', '1M'], + preambles: ['EZF'], + }); + }); + + test('decodes full load sheet', () => { + message.text = + 'EZF\nNO0246/10/EI-NEO\n/C28Y331/3/9\n-SCT/MXP-ZNZ\n-STD/2200\n-FLT STATUS/CLOSED\n-UOM/KG\n-ZFW/162075\n-PAX/305\n-PXT/122/160/19/02\n-PXW/22601\n-CGO/3573\n-BAG/4783\n-OTH/4336\n-TTL/35293\n-FWT/51063\n-TOW/213138\n-DOW/126782'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.decoder.name).toBe('label-h1-ezf'); + expect(decodeResult.formatted.description).toBe('Load Sheet'); + expect(decodeResult.message).toBe(message); + expect(decodeResult.raw.flight_number).toBe('NO0246'); + expect(decodeResult.raw.tail).toBe('EI-NEO'); + expect(decodeResult.raw.departure_iata).toBe('MXP'); + expect(decodeResult.raw.arrival_iata).toBe('ZNZ'); + expect(decodeResult.raw.loadsheet['ZFW']).toBe('162075'); + expect(decodeResult.raw.loadsheet['PAX']).toBe('305'); + expect(decodeResult.raw.loadsheet['TOW']).toBe('213138'); + expect(decodeResult.raw.loadsheet['FLT STATUS']).toBe('CLOSED'); + expect(decodeResult.raw.loadsheet['UOM']).toBe('KG'); + }); + + test('decodes minimal load sheet', () => { + message.text = 'EZF\nAB1234/05/D-ABCD\n-SCT/JFK-LAX\n-PAX/180\n-ZFW/55000'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.flight_number).toBe('AB1234'); + expect(decodeResult.raw.tail).toBe('D-ABCD'); + expect(decodeResult.raw.departure_iata).toBe('JFK'); + expect(decodeResult.raw.arrival_iata).toBe('LAX'); + expect(decodeResult.raw.loadsheet['PAX']).toBe('180'); + expect(decodeResult.raw.loadsheet['ZFW']).toBe('55000'); + }); + + test('decodes with label 1M', () => { + const msg1M = { label: '1M', text: 'EZF\nXY5678/12/G-TEST\n-SCT/LHR-CDG\n-PAX/90' }; + const decodeResult = plugin.decode(msg1M); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.flight_number).toBe('XY5678'); + expect(decodeResult.raw.departure_iata).toBe('LHR'); + expect(decodeResult.raw.arrival_iata).toBe('CDG'); + }); + + test('rejects invalid message', () => { + message.text = 'NOT_EZF data here'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.decoder.name).toBe('label-h1-ezf'); + expect(decodeResult.remaining.text).toBe(message.text); + }); + + test('rejects message with only EZF header', () => { + message.text = 'EZF'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); +}); diff --git a/lib/plugins/Label_H1_EZF.ts b/lib/plugins/Label_H1_EZF.ts new file mode 100644 index 00000000..f47fb664 --- /dev/null +++ b/lib/plugins/Label_H1_EZF.ts @@ -0,0 +1,110 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; + +export class Label_H1_EZF extends DecoderPlugin { + name = 'label-h1-ezf'; + + qualifiers() { + return { + labels: ['H1', '1M'], + preambles: ['EZF'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + let decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.formatted.description = 'Load Sheet'; + decodeResult.message = message; + + const lines = message.text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + + if (lines.length < 2 || lines[0] !== 'EZF') { + if (options.debug) { + console.log(`Decoder: Unknown H1 EZF message: ${message.text}`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + const loadsheet: Record = {}; + const unknowns: string[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('-')) { + const kvMatch = line.match(/^-([^/]+)\/(.+)$/); + if (kvMatch) { + loadsheet[kvMatch[1]] = kvMatch[2]; + } else { + unknowns.push(line); + } + } else if (i === 1) { + // Second line is the flight identifier, e.g. NO0246/10/EI-NEO + const idParts = line.split('/'); + if (idParts.length >= 1) { + ResultFormatter.flightNumber(decodeResult, idParts[0]); + } + if (idParts.length >= 3) { + ResultFormatter.tail(decodeResult, idParts[2]); + } + // Remaining parts of identifier line + if (idParts.length >= 2) { + // idParts[1] is day or other info - store but don't format + } + } else if (i === 2) { + // Third line typically contains config info, e.g. /C28Y331/3/9 + unknowns.push(line); + } else { + unknowns.push(line); + } + } + + // Extract known fields from loadsheet + if (loadsheet['SCT']) { + const route = loadsheet['SCT'].split('-'); + if (route.length === 2) { + ResultFormatter.departureAirport(decodeResult, route[0], 'IATA'); + ResultFormatter.arrivalAirport(decodeResult, route[1], 'IATA'); + } + } + + // Store all loadsheet data in raw + decodeResult.raw.loadsheet = loadsheet; + + // Format key loadsheet items + const formatFields: [string, string, string][] = [ + ['STD', 'Scheduled Time of Departure', 'std'], + ['FLT STATUS', 'Flight Status', 'flight_status'], + ['UOM', 'Unit of Measure', 'uom'], + ['ZFW', 'Zero Fuel Weight', 'zfw'], + ['PAX', 'Passengers', 'pax'], + ['TOW', 'Takeoff Weight', 'tow'], + ['DOW', 'Dry Operating Weight', 'dow'], + ['FWT', 'Fuel Weight', 'fuel_weight'], + ]; + + for (const [key, label, code] of formatFields) { + if (loadsheet[key]) { + decodeResult.formatted.items.push({ + type: 'loadsheet', + code: code.toUpperCase(), + label: label, + value: loadsheet[key], + }); + } + } + + if (unknowns.length > 0) { + ResultFormatter.unknownArr(decodeResult, unknowns, '\n'); + } + + decodeResult.decoded = true; + decodeResult.decoder.decodeLevel = 'partial'; + + return decodeResult; + } +} diff --git a/lib/plugins/Label_H1_M_POS.test.ts b/lib/plugins/Label_H1_M_POS.test.ts new file mode 100644 index 00000000..7f3b852d --- /dev/null +++ b/lib/plugins/Label_H1_M_POS.test.ts @@ -0,0 +1,90 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_M_POS } from './Label_H1_M_POS'; + +describe('Label H1 M-Series Position Report', () => { + let plugin: Label_H1_M_POS; + const message = { label: 'H1', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_H1_M_POS(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-h1-m-pos'); + expect(plugin.qualifiers()).toEqual({ + labels: ['H1'], + }); + }); + + test('decodes M85 position report', () => { + message.text = + 'M85AQF0073YSSY,KSFO,101621,- 4.9985,-169.9820,35003,290, 35.1, 44100,S05W169,S02W167,1645'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.decoder.name).toBe('label-h1-m-pos'); + expect(decodeResult.formatted.description).toBe( + 'M-Series Periodic Position Report', + ); + expect(decodeResult.message).toBe(message); + expect(decodeResult.raw.flight_number).toBe('QF0073'); + expect(decodeResult.raw.departure_icao).toBe('YSSY'); + expect(decodeResult.raw.arrival_icao).toBe('KSFO'); + expect(decodeResult.raw.day).toBe(10); + expect(decodeResult.raw.position.latitude).toBeCloseTo(-4.9985, 4); + expect(decodeResult.raw.position.longitude).toBeCloseTo(-169.982, 4); + expect(decodeResult.raw.altitude).toBe(35003); + expect(decodeResult.raw.heading).toBe(290); + }); + + test('decodes M87 position report', () => { + message.text = + 'M87AQF0073YSSY,KSFO,101702, 0.7144,-166.0643,36000,282, 34.1, 40200,S00W166,N03W162,1739'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.flight_number).toBe('QF0073'); + expect(decodeResult.raw.departure_icao).toBe('YSSY'); + expect(decodeResult.raw.arrival_icao).toBe('KSFO'); + expect(decodeResult.raw.position.latitude).toBeCloseTo(0.7144, 4); + expect(decodeResult.raw.position.longitude).toBeCloseTo(-166.0643, 4); + expect(decodeResult.raw.altitude).toBe(36000); + expect(decodeResult.raw.heading).toBe(282); + }); + + test('decodes M89 position report', () => { + message.text = + 'M89AQF0073YSSY,KSFO,101819, 7.2016,-158.1146,37001,275, 33.5, 32700,N07W158,N11W153,1900'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.flight_number).toBe('QF0073'); + expect(decodeResult.raw.position.latitude).toBeCloseTo(7.2016, 4); + expect(decodeResult.raw.position.longitude).toBeCloseTo(-158.1146, 4); + expect(decodeResult.raw.altitude).toBe(37001); + expect(decodeResult.raw.heading).toBe(275); + }); + + test('rejects invalid message', () => { + message.text = 'FLR/FR24030411230034583106FWC2'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.decoder.name).toBe('label-h1-m-pos'); + expect(decodeResult.remaining.text).toBe(message.text); + }); + + test('rejects message with too few fields', () => { + message.text = 'M85AQF0073YSSY,KSFO'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); +}); diff --git a/lib/plugins/Label_H1_M_POS.ts b/lib/plugins/Label_H1_M_POS.ts new file mode 100644 index 00000000..3afb22e5 --- /dev/null +++ b/lib/plugins/Label_H1_M_POS.ts @@ -0,0 +1,84 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; +import { DateTimeUtils } from '../DateTimeUtils'; + +export class Label_H1_M_POS extends DecoderPlugin { + name = 'label-h1-m-pos'; + + qualifiers() { + return { + labels: ['H1'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + let decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.formatted.description = 'M-Series Periodic Position Report'; + decodeResult.message = message; + + // Match M[2-digit seq]A[airline 2-char][flight 4-digit][origin],[dest],[DDHHMM],[lat],[lon],[alt],[hdg],... + const headerRegex = /^M(\d{2})A([A-Z]{2})(\d{4})/; + const headerMatch = message.text.match(headerRegex); + + if (!headerMatch) { + if (options.debug) { + console.log(`Decoder: Unknown H1 M-POS message: ${message.text}`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + const airline = headerMatch[2]; + const flightNum = headerMatch[3]; + const afterHeader = message.text.substring(headerMatch[0].length); + const fields = afterHeader.split(','); + + // We expect at least: origin, dest, DDHHMM, lat, lon, alt, hdg + if (fields.length < 7) { + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.decoded = false; + decodeResult.decoder.decodeLevel = 'none'; + return decodeResult; + } + + ResultFormatter.flightNumber(decodeResult, `${airline}${flightNum}`); + ResultFormatter.departureAirport(decodeResult, fields[0]); + ResultFormatter.arrivalAirport(decodeResult, fields[1]); + + // DDHHMM timestamp + const timestamp = fields[2].trim(); + if (timestamp.length === 6) { + const day = Number(timestamp.substring(0, 2)); + const tod = DateTimeUtils.convertHHMMSSToTod(timestamp.substring(2) + '00'); + ResultFormatter.day(decodeResult, day); + ResultFormatter.time_of_day(decodeResult, tod); + } + + // Latitude and longitude (trim spaces, e.g. "- 4.9985") + const lat = parseFloat(fields[3].replace(/\s/g, '')); + const lon = parseFloat(fields[4].replace(/\s/g, '')); + ResultFormatter.position(decodeResult, { latitude: lat, longitude: lon }); + + // Altitude + const alt = Number(fields[5]); + ResultFormatter.altitude(decodeResult, alt); + + // Heading + const hdg = Number(fields[6]); + ResultFormatter.heading(decodeResult, hdg); + + // Remaining fields as unknown + if (fields.length > 7) { + ResultFormatter.unknownArr(decodeResult, fields.slice(7)); + } + + decodeResult.decoded = true; + decodeResult.decoder.decodeLevel = fields.length > 7 ? 'partial' : 'full'; + + return decodeResult; + } +} diff --git a/lib/plugins/official.ts b/lib/plugins/official.ts index 728ef908..fe0ea44a 100644 --- a/lib/plugins/official.ts +++ b/lib/plugins/official.ts @@ -49,7 +49,10 @@ export * from './Label_ColonComma'; export * from './Label_H1'; export * from './Label_H1_OFP'; export * from './Label_H2_02E'; +export * from './Label_H1_ATIS'; +export * from './Label_H1_EZF'; export * from './Label_H1_FLR'; +export * from './Label_H1_M_POS'; export * from './Label_H1_OHMA'; export * from './Label_H1_Paren'; export * from './Label_H1_StarPOS'; From 2e1f82b7f3398defcdc2e90a9a38206553772c10 Mon Sep 17 00:00:00 2001 From: Mark Bumiller Date: Sun, 15 Mar 2026 20:41:58 -0400 Subject: [PATCH 3/6] fixes --- lib/plugins/Label_H1_M_POS.test.ts | 12 ++++++++++++ lib/plugins/Label_H1_OFP.test.ts | 22 ++++++++++++++++++---- lib/plugins/Label_H1_OFP.ts | 2 +- lib/utils/icao_fpl_utils.ts | 14 +++++++++++--- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/lib/plugins/Label_H1_M_POS.test.ts b/lib/plugins/Label_H1_M_POS.test.ts index 7f3b852d..373c3f6d 100644 --- a/lib/plugins/Label_H1_M_POS.test.ts +++ b/lib/plugins/Label_H1_M_POS.test.ts @@ -38,6 +38,10 @@ describe('Label H1 M-Series Position Report', () => { expect(decodeResult.raw.position.longitude).toBeCloseTo(-169.982, 4); expect(decodeResult.raw.altitude).toBe(35003); expect(decodeResult.raw.heading).toBe(290); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 35.1, 44100,S05W169,S02W167,1645', + ); }); test('decodes M87 position report', () => { @@ -54,6 +58,10 @@ describe('Label H1 M-Series Position Report', () => { expect(decodeResult.raw.position.longitude).toBeCloseTo(-166.0643, 4); expect(decodeResult.raw.altitude).toBe(36000); expect(decodeResult.raw.heading).toBe(282); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 34.1, 40200,S00W166,N03W162,1739', + ); }); test('decodes M89 position report', () => { @@ -68,6 +76,10 @@ describe('Label H1 M-Series Position Report', () => { expect(decodeResult.raw.position.longitude).toBeCloseTo(-158.1146, 4); expect(decodeResult.raw.altitude).toBe(37001); expect(decodeResult.raw.heading).toBe(275); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 33.5, 32700,N07W158,N11W153,1900', + ); }); test('rejects invalid message', () => { diff --git a/lib/plugins/Label_H1_OFP.test.ts b/lib/plugins/Label_H1_OFP.test.ts index 19465f1c..689584f4 100644 --- a/lib/plugins/Label_H1_OFP.test.ts +++ b/lib/plugins/Label_H1_OFP.test.ts @@ -46,7 +46,15 @@ describe('Label H1 OFP (ICAO Flight Plan)', () => { expect(decodeResult.raw.arrival_icao).toBe('KLAX'); expect(decodeResult.raw.alternate_icao).toBe('KSFO'); expect(decodeResult.raw.tail).toBe('B2036'); - expect(decodeResult.raw.flight_plan).toBe('DCT ENI DCT OAK DCT BURGL IRNMN2'); + expect(decodeResult.raw.route.waypoints).toStrictEqual([ + { name: 'DCT' }, + { name: 'ENI' }, + { name: 'DCT' }, + { name: 'OAK' }, + { name: 'DCT' }, + { name: 'BURGL' }, + { name: 'IRNMN2' }, + ]); // Check formatted items const items = decodeResult.formatted.items; @@ -55,8 +63,8 @@ describe('Label H1 OFP (ICAO Flight Plan)', () => { expect(items.find((i) => i.code === 'ORG')!.value).toBe('VESPA'); expect(items.find((i) => i.code === 'DST')!.value).toBe('KLAX'); expect(items.find((i) => i.code === 'ALT_DST')!.value).toBe('KSFO'); - expect(items.find((i) => i.code === 'FPN')!.value).toBe( - 'DCT ENI DCT OAK DCT BURGL IRNMN2', + expect(items.find((i) => i.code === 'ROUTE')!.value).toBe( + 'DCT > ENI > DCT > OAK > DCT > BURGL > IRNMN2', ); expect(items.find((i) => i.code === 'TAIL')!.value).toBe('B2036'); expect(items.find((i) => i.code === 'RULES')!.value).toBe('IFR'); @@ -78,7 +86,13 @@ describe('Label H1 OFP (ICAO Flight Plan)', () => { expect(decodeResult.raw.arrival_icao).toBe('KLAX'); expect(decodeResult.raw.alternate_icao).toBe('KONT'); expect(decodeResult.raw.tail).toBe('N22992'); - expect(decodeResult.raw.flight_plan).toBe('DCT PORTE J584 ENI DCT'); + expect(decodeResult.raw.route.waypoints).toStrictEqual([ + { name: 'DCT' }, + { name: 'PORTE' }, + { name: 'J584' }, + { name: 'ENI' }, + { name: 'DCT' }, + ]); }); test('does not decode message without FPL block', () => { diff --git a/lib/plugins/Label_H1_OFP.ts b/lib/plugins/Label_H1_OFP.ts index 1bd4f09e..fe0756c3 100644 --- a/lib/plugins/Label_H1_OFP.ts +++ b/lib/plugins/Label_H1_OFP.ts @@ -59,7 +59,7 @@ export class Label_H1_OFP extends DecoderPlugin { } if (fpl.route) { - ResultFormatter.flightPlan(decodeResult, fpl.route); + ResultFormatter.route(decodeResult, fpl.route); } if (fpl.otherInfo.REG) { diff --git a/lib/utils/icao_fpl_utils.ts b/lib/utils/icao_fpl_utils.ts index 8b25cc3a..41cc67a8 100644 --- a/lib/utils/icao_fpl_utils.ts +++ b/lib/utils/icao_fpl_utils.ts @@ -10,6 +10,8 @@ * -otherInfo) */ +import { Route } from '../types/route'; + export interface IcaoFlightPlan { callsign: string; flightRules: string; @@ -22,7 +24,7 @@ export interface IcaoFlightPlan { departureTime: string; cruiseSpeed: string; cruiseLevel: string; - route: string; + route: Route; destination: string; eet: string; alternates: string[]; @@ -117,13 +119,19 @@ export function parseIcaoFpl(text: string): IcaoFlightPlan | null { // Section 6: cruise speed/level + route (e.g., "N0482F350 DCT ENI DCT OAK DCT BURGL IRNMN2") const speedRouteField = parts[5].trim(); // Speed is first token like N0482 or M084, level follows like F350 or S1190 - const speedMatch = speedRouteField.match(/^([NKM]\d{3,4})([FAVMS]\d{3,4})\s*(.*)/); + const speedMatch = speedRouteField.match( + /^([NKM]\d{3,4})([FAVMS]\d{3,4})\s*(.*)/, + ); if (!speedMatch) { return null; } const cruiseSpeed = speedMatch[1]; const cruiseLevel = speedMatch[2]; - const route = speedMatch[3] || ''; + const route = { + waypoints: speedMatch[3].split(' ').map((s) => ({ + name: s, + })), + }; // Section 7: destination + EET + alternates (e.g., "KLAX0117 KSFO") const destField = parts[6].trim(); From bd1e2f52dcf9a1875167ab9c2b4b775f5a2026b6 Mon Sep 17 00:00:00 2001 From: Mark Bumiller Date: Sun, 15 Mar 2026 20:42:23 -0400 Subject: [PATCH 4/6] linter --- lib/plugins/Label_H1_ATIS.test.ts | 6 +++--- lib/plugins/Label_H1_ATIS.ts | 3 ++- lib/plugins/Label_H1_EZF.test.ts | 5 ++++- lib/plugins/Label_H1_EZF.ts | 5 ++++- lib/plugins/Label_H1_FPN.test.ts | 6 ++++-- lib/plugins/Label_H1_M_POS.ts | 4 +++- lib/plugins/Label_H1_POS.test.ts | 3 ++- lib/utils/icao_fpl_utils.test.ts | 4 +++- 8 files changed, 25 insertions(+), 11 deletions(-) diff --git a/lib/plugins/Label_H1_ATIS.test.ts b/lib/plugins/Label_H1_ATIS.test.ts index 8fad9f46..26be2c58 100644 --- a/lib/plugins/Label_H1_ATIS.test.ts +++ b/lib/plugins/Label_H1_ATIS.test.ts @@ -31,7 +31,7 @@ describe('Label H1 ATIS Subscription', () => { expect(decodeResult.raw.flight_number).toBe('QF0073'); expect(decodeResult.raw.arrival_icao).toBe('KSFO'); expect(decodeResult.raw.atis_code).toBe('030'); - expect(decodeResult.raw.checksum).toBe(0xAFF5C); + expect(decodeResult.raw.checksum).toBe(0xaff5c); }); test('decodes OERK ATIS subscription', () => { @@ -43,7 +43,7 @@ describe('Label H1 ATIS Subscription', () => { expect(decodeResult.raw.flight_number).toBe('MC5477'); expect(decodeResult.raw.arrival_icao).toBe('OERK'); expect(decodeResult.raw.atis_code).toBe('024'); - expect(decodeResult.raw.checksum).toBe(0xC8781); + expect(decodeResult.raw.checksum).toBe(0xc8781); }); test('decodes LICC ATIS subscription', () => { @@ -55,7 +55,7 @@ describe('Label H1 ATIS Subscription', () => { expect(decodeResult.raw.flight_number).toBe('MC5477'); expect(decodeResult.raw.arrival_icao).toBe('LICC'); expect(decodeResult.raw.atis_code).toBe('024'); - expect(decodeResult.raw.checksum).toBe(0xAFBD9); + expect(decodeResult.raw.checksum).toBe(0xafbd9); }); test('decodes with label 5Z', () => { diff --git a/lib/plugins/Label_H1_ATIS.ts b/lib/plugins/Label_H1_ATIS.ts index 16d114f1..41bdeae8 100644 --- a/lib/plugins/Label_H1_ATIS.ts +++ b/lib/plugins/Label_H1_ATIS.ts @@ -20,7 +20,8 @@ export class Label_H1_ATIS extends DecoderPlugin { decodeResult.message = message; // Pattern: L[2-digit seq]A[flight]/[facility].TI2/[code][airport][checksum] - const regex = /^L(\d{2})A([A-Z0-9]+)\/([A-Z]{4})\.TI2\/(\d{3})([A-Z]{4})([A-F0-9]+)$/; + const regex = + /^L(\d{2})A([A-Z0-9]+)\/([A-Z]{4})\.TI2\/(\d{3})([A-Z]{4})([A-F0-9]+)$/; const match = message.text.match(regex); if (!match) { diff --git a/lib/plugins/Label_H1_EZF.test.ts b/lib/plugins/Label_H1_EZF.test.ts index dc07652c..5e415605 100644 --- a/lib/plugins/Label_H1_EZF.test.ts +++ b/lib/plugins/Label_H1_EZF.test.ts @@ -55,7 +55,10 @@ describe('Label H1 Preamble EZF Load Sheet', () => { }); test('decodes with label 1M', () => { - const msg1M = { label: '1M', text: 'EZF\nXY5678/12/G-TEST\n-SCT/LHR-CDG\n-PAX/90' }; + const msg1M = { + label: '1M', + text: 'EZF\nXY5678/12/G-TEST\n-SCT/LHR-CDG\n-PAX/90', + }; const decodeResult = plugin.decode(msg1M); expect(decodeResult.decoded).toBe(true); diff --git a/lib/plugins/Label_H1_EZF.ts b/lib/plugins/Label_H1_EZF.ts index f47fb664..2d4ad10e 100644 --- a/lib/plugins/Label_H1_EZF.ts +++ b/lib/plugins/Label_H1_EZF.ts @@ -18,7 +18,10 @@ export class Label_H1_EZF extends DecoderPlugin { decodeResult.formatted.description = 'Load Sheet'; decodeResult.message = message; - const lines = message.text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + const lines = message.text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); if (lines.length < 2 || lines[0] !== 'EZF') { if (options.debug) { diff --git a/lib/plugins/Label_H1_FPN.test.ts b/lib/plugins/Label_H1_FPN.test.ts index fa2870e7..199b7239 100644 --- a/lib/plugins/Label_H1_FPN.test.ts +++ b/lib/plugins/Label_H1_FPN.test.ts @@ -171,7 +171,7 @@ describe('Label_H1 FPN', () => { ); //TODO - pull out route expect(decodeResult.formatted.items[4].label).toBe('Message Checksum'); expect(decodeResult.formatted.items[4].value).toBe('0x0560'); - expect(decodeResult.remaining.text).toBe(''); + expect(decodeResult.remaining.text).toBe(''); }); test('decodes Label H1 Preamble FPN with SN and TS', () => { @@ -295,7 +295,9 @@ describe('Label_H1 FPN', () => { expect(decodeResult.formatted.items.length).toBe(13); expect(decodeResult.raw.altitude).toBe(11000); expect(decodeResult.raw.outside_air_temperature).toBe(-7); - expect(decodeResult.remaining.text).toBe('F37A#M1B/,,,183,7,13,,25,,,P30,M40,36090,13,3455,300'); + expect(decodeResult.remaining.text).toBe( + 'F37A#M1B/,,,183,7,13,,25,,,P30,M40,36090,13,3455,300', + ); }); test('decodes Label H1 Preamble FPN ', () => { diff --git a/lib/plugins/Label_H1_M_POS.ts b/lib/plugins/Label_H1_M_POS.ts index 3afb22e5..dd33e3a2 100644 --- a/lib/plugins/Label_H1_M_POS.ts +++ b/lib/plugins/Label_H1_M_POS.ts @@ -53,7 +53,9 @@ export class Label_H1_M_POS extends DecoderPlugin { const timestamp = fields[2].trim(); if (timestamp.length === 6) { const day = Number(timestamp.substring(0, 2)); - const tod = DateTimeUtils.convertHHMMSSToTod(timestamp.substring(2) + '00'); + const tod = DateTimeUtils.convertHHMMSSToTod( + timestamp.substring(2) + '00', + ); ResultFormatter.day(decodeResult, day); ResultFormatter.time_of_day(decodeResult, tod); } diff --git a/lib/plugins/Label_H1_POS.test.ts b/lib/plugins/Label_H1_POS.test.ts index adb1943e..b0dc7312 100644 --- a/lib/plugins/Label_H1_POS.test.ts +++ b/lib/plugins/Label_H1_POS.test.ts @@ -231,7 +231,8 @@ describe('Label_H1 POS', () => { expect(decodeResult.formatted.items[13].label).toBe('Message Checksum'); expect(decodeResult.formatted.items[13].value).toBe('0x9d1c'); expect(decodeResult.remaining.text).toBe( - '1754,231,189,,0,0,,185,,,P16,P0,36000,,1565,250'); + '1754,231,189,,0,0,,185,,,P16,P0,36000,,1565,250', + ); }); test('variant 1 with offset', () => { diff --git a/lib/utils/icao_fpl_utils.test.ts b/lib/utils/icao_fpl_utils.test.ts index 052d8013..cd957b06 100644 --- a/lib/utils/icao_fpl_utils.test.ts +++ b/lib/utils/icao_fpl_utils.test.ts @@ -81,7 +81,9 @@ describe('ICAO FPL Parser', () => { }); test('returns null for missing closing paren', () => { - const result = parseIcaoFpl('(FPL-CCA769-IS-B77W/H-EQUIP/SURV-KSFO0100-N0482F350 DCT'); + const result = parseIcaoFpl( + '(FPL-CCA769-IS-B77W/H-EQUIP/SURV-KSFO0100-N0482F350 DCT', + ); expect(result).toBeNull(); }); }); From c4aeba11fc592d0cfaf9c3f5b24e19a3f66517ee Mon Sep 17 00:00:00 2001 From: Mark Bumiller Date: Sun, 15 Mar 2026 20:45:29 -0400 Subject: [PATCH 5/6] fix --- lib/utils/icao_fpl_utils.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/utils/icao_fpl_utils.test.ts b/lib/utils/icao_fpl_utils.test.ts index cd957b06..ba1283f8 100644 --- a/lib/utils/icao_fpl_utils.test.ts +++ b/lib/utils/icao_fpl_utils.test.ts @@ -27,7 +27,17 @@ describe('ICAO FPL Parser', () => { expect(result!.departureTime).toBe('2354'); expect(result!.cruiseSpeed).toBe('N0482'); expect(result!.cruiseLevel).toBe('F350'); - expect(result!.route).toBe('DCT ENI DCT OAK DCT BURGL IRNMN2'); + expect(result!.route).toStrictEqual({ + waypoints: [ + { name: 'DCT' }, + { name: 'ENI' }, + { name: 'DCT' }, + { name: 'OAK' }, + { name: 'DCT' }, + { name: 'BURGL' }, + { name: 'IRNMN2' }, + ], + }); expect(result!.destination).toBe('KLAX'); expect(result!.eet).toBe('0117'); expect(result!.alternates).toEqual(['KSFO']); @@ -61,7 +71,15 @@ describe('ICAO FPL Parser', () => { expect(result!.departureTime).toBe('0100'); expect(result!.cruiseSpeed).toBe('N0487'); expect(result!.cruiseLevel).toBe('F370'); - expect(result!.route).toBe('DCT PORTE J584 ENI DCT'); + expect(result!.route).toStrictEqual({ + waypoints: [ + { name: 'DCT' }, + { name: 'PORTE' }, + { name: 'J584' }, + { name: 'ENI' }, + { name: 'DCT' }, + ], + }); expect(result!.destination).toBe('KLAX'); expect(result!.eet).toBe('0055'); expect(result!.alternates).toEqual(['KONT']); From 4ae1f662065d47486c58cbb83b96572dd728443d Mon Sep 17 00:00:00 2001 From: Mark Bumiller Date: Sun, 15 Mar 2026 20:49:37 -0400 Subject: [PATCH 6/6] merge fix --- lib/plugins/Label_H1_M_POS.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plugins/Label_H1_M_POS.ts b/lib/plugins/Label_H1_M_POS.ts index dd33e3a2..92a88b1d 100644 --- a/lib/plugins/Label_H1_M_POS.ts +++ b/lib/plugins/Label_H1_M_POS.ts @@ -57,7 +57,7 @@ export class Label_H1_M_POS extends DecoderPlugin { timestamp.substring(2) + '00', ); ResultFormatter.day(decodeResult, day); - ResultFormatter.time_of_day(decodeResult, tod); + ResultFormatter.timestamp(decodeResult, tod); } // Latitude and longitude (trim spaces, e.g. "- 4.9985")