diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index fc81f950..49d2e1dd 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -61,8 +61,12 @@ 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_OFP(this)); this.registerPlugin(new Plugins.Label_H1_Paren(this)); this.registerPlugin(new Plugins.Label_H1_WRN(this)); this.registerPlugin(new Plugins.Label_H1_StarPOS(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..26be2c58 --- /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..41bdeae8 --- /dev/null +++ b/lib/plugins/Label_H1_ATIS.ts @@ -0,0 +1,74 @@ +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..5e415605 --- /dev/null +++ b/lib/plugins/Label_H1_EZF.test.ts @@ -0,0 +1,87 @@ +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..2d4ad10e --- /dev/null +++ b/lib/plugins/Label_H1_EZF.ts @@ -0,0 +1,113 @@ +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_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.test.ts b/lib/plugins/Label_H1_M_POS.test.ts new file mode 100644 index 00000000..373c3f6d --- /dev/null +++ b/lib/plugins/Label_H1_M_POS.test.ts @@ -0,0 +1,102 @@ +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); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 35.1, 44100,S05W169,S02W167,1645', + ); + }); + + 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); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 34.1, 40200,S00W166,N03W162,1739', + ); + }); + + 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); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.remaining.text).toBe( + ' 33.5, 32700,N07W158,N11W153,1900', + ); + }); + + 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..92a88b1d --- /dev/null +++ b/lib/plugins/Label_H1_M_POS.ts @@ -0,0 +1,86 @@ +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.timestamp(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/Label_H1_OFP.test.ts b/lib/plugins/Label_H1_OFP.test.ts new file mode 100644 index 00000000..689584f4 --- /dev/null +++ b/lib/plugins/Label_H1_OFP.test.ts @@ -0,0 +1,122 @@ +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.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; + 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 === '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'); + 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.route.waypoints).toStrictEqual([ + { name: 'DCT' }, + { name: 'PORTE' }, + { name: 'J584' }, + { name: 'ENI' }, + { name: '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..fe0756c3 --- /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.route(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/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/plugins/official.ts b/lib/plugins/official.ts index f92cf5bd..0d3d6fac 100644 --- a/lib/plugins/official.ts +++ b/lib/plugins/official.ts @@ -1,4 +1,5 @@ export * from './CBand'; +export * from './ARINC_702'; export * from './Label_5Z_Slash'; export * from './Label_10_LDR'; export * from './Label_10_POS'; @@ -46,10 +47,13 @@ export * from './Label_83'; export * from './Label_8E'; export * from './Label_B6'; export * from './Label_ColonComma'; -export * from './ARINC_702'; 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_OFP'; export * from './Label_H1_Paren'; export * from './Label_H1_StarPOS'; export * from './Label_H1_WRN'; diff --git a/lib/utils/icao_fpl_utils.test.ts b/lib/utils/icao_fpl_utils.test.ts new file mode 100644 index 00000000..ba1283f8 --- /dev/null +++ b/lib/utils/icao_fpl_utils.test.ts @@ -0,0 +1,107 @@ +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).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']); + 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).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']); + 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..41cc67a8 --- /dev/null +++ b/lib/utils/icao_fpl_utils.ts @@ -0,0 +1,195 @@ +/** + * 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) + */ + +import { Route } from '../types/route'; + +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: Route; + 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 = { + waypoints: speedMatch[3].split(' ').map((s) => ({ + name: s, + })), + }; + + // 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, + }; +}