From 4d956686d32482e2f49bdd4a4e66ec00e337d42b Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:08:16 -0400 Subject: [PATCH 1/2] feat(plugins): decode the Airbus ACMS A02 cruise performance report A319, A320 and A321s send the ACMS cruise performance report <02> on H1: a header, then /-separated blocks for the flight, report codes, air data, per-engine data and aircraft data. Nothing decoded it. Every field is shown under its AMM 31-37-73 name. A value is scaled only where its scaling is settled (checked against 11 reports from at least 7 operators); every other value is shown as sent and marked unscaled, so no decimal point is guessed. Gross weight and fuel flow take their units from the unit configuration code (09/11 pounds, 42/45/46 kilograms). The engine blocks of IAE-engined aircraft have another layout and are shown as sent. A copy of the first block alone decodes as far as it goes. Co-Authored-By: Claude Opus 5.5 --- lib/MessageDecoder.ts | 1 + lib/plugins/Label_H1_A02.test.ts | 166 +++++++++ lib/plugins/Label_H1_A02.ts | 598 +++++++++++++++++++++++++++++++ lib/plugins/official.ts | 1 + 4 files changed, 766 insertions(+) create mode 100644 lib/plugins/Label_H1_A02.test.ts create mode 100644 lib/plugins/Label_H1_A02.ts diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index 676fdedd..8c33e10f 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -61,6 +61,7 @@ const pluginClasses = [ Plugins.Label_4T_ETA, Plugins.Label_B6_Forwardslash, Plugins.Label_H1_76401, + Plugins.Label_H1_A02, Plugins.Label_H1_ATIS, Plugins.Label_H1_EZF, Plugins.Label_H1_FLR, diff --git a/lib/plugins/Label_H1_A02.test.ts b/lib/plugins/Label_H1_A02.test.ts new file mode 100644 index 00000000..025cb68f --- /dev/null +++ b/lib/plugins/Label_H1_A02.test.ts @@ -0,0 +1,166 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_H1_A02 } from './Label_H1_A02'; + +describe('Label H1 A02', () => { + let plugin: Label_H1_A02; + const message = { label: 'H1', text: '' }; + const item = (result: ReturnType, label: string) => + result.formatted.items.find((i) => i.label === label)?.value; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_H1_A02(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.decode).toBeDefined(); + expect(plugin.name).toBe('label-h1-a02'); + expect(plugin.qualifiers()).toEqual({ + labels: ['H1'], + preambles: ['A02/A'], + }); + }); + + test('decodes a CFM report without a unit configuration', () => { + // A319 N757UW, KORD-KDCA + message.text = + 'A02/A31902,1,1/CCN757UW,OCT30,014329,KORD,KDCA,2769/C106,80038,5000,XX,0010,0,0100,XX,X/CEN340,36996,244,758,1163,270,C83014/CNN336,36986,244,757,1163,270/EC779584,01027,14266,00639,73,22,11/EE575632,01027,04948,00639,73/N10796,0796,0900,5609,0217,XXXXX/N20796,0796,0899,5665,0218,XXXXX/S1XXXXX,0463,1040,3913,XXXX,030,N00/S2XXXXX,0470,1019,3846,XXXX,033,N00/T1099,100,025,35,055,04626,0224/T2099,099,018,36,061,04596,0156/V104,06,041,215,01,01,00004/V209,05,158,251,04,00,00000/V308,04,148,270,0614/V410,05,261,084,0613/V5XX,XX,XXX,XXX,XXXX/V6XX,XX,XXX,XXX,XXXX/V7041,082,00081,22222222222111/V8038,097,00081,22222222222111/X100362,N003,0025,0002,N0006,N013/X200425,N005,0023,0002,N0007,N013/X30000,0005,0000,N007,N007,0001,N000/X4N000,N001,N001,N001,0000,0000/X5N000,N001,N000,0000,0000,0000/X61386,W0822,N410,073,289,0169,XXXX/X71386,W0822,N410,073,289,0150,XXXX,/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe( + 'Cruise Performance Report', + ); + expect(decodeResult.remaining.text).toBeUndefined(); + expect(decodeResult.raw.aircraft_type).toBe('A319'); + expect(decodeResult.raw.tail).toBe('N757UW'); + expect(decodeResult.raw.departure_icao).toBe('KORD'); + expect(decodeResult.raw.arrival_icao).toBe('KDCA'); + expect(decodeResult.raw.flight_number).toBe('2769'); + expect(item(decodeResult, 'Report Time')).toBe('01:43:29'); + expect(item(decodeResult, 'Unit Configuration')).toBe( + 'XX (units not sent)', + ); + // settled scaling + expect(item(decodeResult, 'Total Air Temperature')).toBe( + '-34.0 C / -33.6 C', + ); + expect(decodeResult.raw.altitude).toBe(36996); + expect(decodeResult.raw.mach).toBe(0.758); + expect(decodeResult.raw.n1).toEqual([79.6, 79.6]); + expect(decodeResult.raw.egt).toEqual([560.9, 566.5]); + expect(decodeResult.raw.descent_n1).toEqual([61.4, 61.3]); + expect(item(decodeResult, 'Climb N1')).toBe( + 'not available / not available', + ); + expect(decodeResult.raw.wing_fuel).toEqual([362, 425]); + expect(decodeResult.raw.true_heading).toEqual([138.6, 138.6]); + expect(decodeResult.raw.position).toEqual({ + latitude: 41, + longitude: -82.2, + }); + expect(decodeResult.raw.wind_direction).toEqual([289, 289]); + expect(decodeResult.raw.fuel_temperature).toEqual([16.9, 15]); + // open scaling: shown as sent + expect(item(decodeResult, 'T25 Compressor Temperature (unscaled)')).toBe( + '0463 / 0470', + ); + expect( + item(decodeResult, 'P3 Compressor Discharge Pressure (unscaled)'), + ).toBe('1040 / 1019'); + expect(item(decodeResult, 'Right Aileron (unscaled)')).toBe('N007'); + // no unit configuration, so no units + expect( + item( + decodeResult, + 'Gross Weight (unscaled, unit configuration not sent)', + ), + ).toBe('1163 / 1163'); + expect( + item(decodeResult, 'Fuel Flow (unscaled, unit configuration not sent)'), + ).toBe('0217 / 0218'); + }); + + test('decodes gross weight and fuel flow in kilograms', () => { + // A320 PR-MHA, SBPA-SABE, unit configuration 42 + message.text = + 'A02/A32002,1,1/CCPR-MHA,APR08,110749,SBPA,SABE,8128/C106,76801,5000,42,0010,0,0100,42,X/CEN164,35997,251,759,6159,354,C31030/CNN157,36015,251,761,6159,354/EC569922,03033,02737,01980,73,14,07/EE643262,03033,02555,01980,73/N10851,0852,0943,6362,1200,XXXXX/N20852,0852,0945,6782,1252,XXXXX/S1XXXXX,0745,1191,4435,XXXX,022,000/S2XXXXX,0766,1204,4540,XXXX,025,N00/T1098,100,026,41,071,04871,XXXX/T2097,099,019,42,100,04885,XXXX/V104,00,116,176,03,00,00000/V205,04,155,164,07,02,00000/V3XX,XX,XXX,XXX,XXXX/V4XX,XX,XXX,XXX,XXXX/V5XX,XX,XXX,XXX,XXXX/V6XX,XX,XXX,XXX,XXXX/V7042,089,00061,22222222222111/V8042,088,00061,22222222222111/X102235,N000,0024,0004,N0003,0006/X202340,N003,0024,0004,N0003,0006/X30003,0010,N008,N005,N003,0005,0000/X4N000,0000,N001,0000,0000,0000/X50000,0000,N001,0000,0000,0000/X62327,W0563,S327,047,271,0131,0810/X72328,W0563,S327,049,270,0135,0804/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(item(decodeResult, 'Unit Configuration')).toBe('42 (kilograms)'); + expect(decodeResult.raw.gross_weight).toBe(61590); + expect(item(decodeResult, 'Gross Weight')).toBe('61590 kg / 61590 kg'); + expect(decodeResult.raw.fuel_flow).toEqual([1200, 1252]); + expect(decodeResult.raw.position).toEqual({ + latitude: -32.7, + longitude: -56.3, + }); + }); + + test('decodes an IAE report, with its engine blocks as sent', () => { + // A320 N668AW, KPIT-KORD, unit configuration 11 + message.text = + 'A02/A32002,1,1/CCN668AW,OCT29,211548,KPIT,KORD,0969/C106,19938,5000,11,0010,0,0100,11,X/CE0044,19989,299,649,1262,327,I21024/CN0050,19999,300,650,1262,327/EC010565,01620,01006,73,30,30/EE012075,01620,01006,73/N11097,1097,3899,0721,0847,0303,11937/N21097,1097,3932,0720,0846,0304,11900/S117270,0650,1559,4077,09931,058/S217247,0653,1553,4037,09904,057/T1069,080,00,025,46,131,08975,0049/T2067,080,00,020,43,182,08984,0046/V103D04,00208,08000,248,108,N089/V203D04,00208,08000,241,108,N129/V3000,000,016/V4011,000,230/X100234,0000,0011,0001,N0014,N001/X200255,0002,0009,0001,N0013,N001/X30002,0009,N007,N008,N003,0003,0000/X4N000,0000,0000,0000,0000,0000/X5N001,N001,0000,0000,0000,0000/X62556,W0863,N426,045,274,0033,0831/X72556,W0863,N426,046,273,0026,0649,/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(item(decodeResult, 'Total Air Temperature')).toBe('4.4 C / 5.0 C'); + expect(decodeResult.raw.gross_weight).toBe(126200); + expect(decodeResult.raw.n1).toBeUndefined(); + expect(item(decodeResult, 'N1 (IAE layout, unscaled)')).toBe( + '1097,1097,3899,0721,0847,0303,11937', + ); + expect(item(decodeResult, 'V3 (IAE layout, unscaled)')).toBe('000,000,016'); + expect(decodeResult.raw.true_heading).toEqual([255.6, 255.6]); + }); + + test('decodes a report sent with line breaks', () => { + // A320 EI-DEF, EIDW-LFPG: '-' for minus and for not available + message.text = + 'A02/A32002,1,6/CCEI-DEF,SEP02,074303,EIDW,LFPG,0522/\nC106,76801,5000,45,0010,0,0100,45,X/\nCE-170,33002,277,781,5913,347,C31I84/\nCN-171,33018,277,782,5912,347/\nEC697779,15955,04185,09152,73,25,17/\nEE577229,24500,15094,14976,73/\nN10827,0827,0933,6220,1285,-----/\nN20827,0827,0932,6229,1262,-----/\nS1-----,0718,1320,4275,----,035,004/\nS2-----,0717,1296,4323,----,035,000/\nT1100,101,026,44,081,05749,0090/\nT2100,101,018,42,095,05841,0104/\nV104,10,168,264,02,01,00000/\nV206,02,006,022,02,00,00000/\nV309,07,253,247,0621/\nV403,04,012,129,0621/\nV502,00,244,241,0931/\nV601,01,225,313,0930/\nV7041,089,00081,22222222222811/\nV8042,090,00061,22222222222G11/\nX101609,-001,0013,0003,-----,----/\nX201780,-001,0013,0003,-----,----/\nX30006,0015,-010,-007,-002,0009,0000/\nX40000,0000,0000,-004,0000,0000/\nX50000,-004,0000,0000,0000,0000/\nX61446,W0024,N527,097,296,0171,0800/\nX71447,W0024,N527,098,296,0167,0806/\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(item(decodeResult, 'Page')).toBe('1 of 6'); + expect(item(decodeResult, 'Total Air Temperature')).toBe( + '-17.0 C / -17.1 C', + ); + expect(item(decodeResult, 'PS13 Static Pressure (unscaled)')).toBe( + 'not available / not available', + ); + expect(decodeResult.raw.climb_n1).toEqual([93.1, 93]); + expect(decodeResult.raw.fuel_flow).toEqual([1285, 1262]); + }); + + test('decodes the first block of a report on its own', () => { + // the copy sent before the rest: the cut block stays undecoded + message.text = + 'A02/A32102,1,1/CCN157UW,OCT18,210148,MMSD,KORD,2636/C106,76801,5000,11,0010,0,0100,11,X/CEN088,33000,270,764,1783,297,C72041/CNN090,33022,271,766,1783,297/EC779867,03259,04451,01353,73,06,02/EE779833,03259,'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.gross_weight).toBe(178300); + expect(decodeResult.raw.engine_serial).toBe('779867'); + expect(decodeResult.remaining.text).toBe('EE779833,03259,'); + }); + + test('is picked by the message decoder', () => { + const decoder = new MessageDecoder(); + const decodeResult = decoder.decode({ + label: 'H1', + text: 'A02/A32102,1,1/CCN157UW,OCT18,210148,MMSD,KORD,2636/C106,76801,5000,11,0010,0,0100,11,X/', + }); + expect(decodeResult.decoder.name).toBe('label-h1-a02'); + }); + + test('does not decode another report', () => { + message.text = 'A02/B12345,1,1/CCN157UW,OCT18,210148,MMSD,KORD,2636/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); +}); diff --git a/lib/plugins/Label_H1_A02.ts b/lib/plugins/Label_H1_A02.ts new file mode 100644 index 00000000..0ac7c53d --- /dev/null +++ b/lib/plugins/Label_H1_A02.ts @@ -0,0 +1,598 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { CoordinateUtils } from '../utils/coordinate_utils'; +import { ResultFormatter } from '../utils/result_formatter'; + +/** + * A field of an A02 block. `scale` is given only where the scaling is settled; every other + * value is shown as sent, and `unscaled` marks the ones whose scaling is still open so they are + * not read as finished numbers. + */ +interface Field { + key: string; + label: string; + scale?: Scale; + unscaled?: true; + /** a raw field with a single value (altitude, mach, gross weight): keep the first block's */ + first?: true; +} + +interface Scale { + divisor?: number; + multiplier?: number; + decimals: number; + unit: string; +} + +interface Group { + blocks: string[]; + /** blocks laid out for CFM engines only; IAE engines send them in another layout */ + cfm?: true; + fields: Field[]; +} + +const tenths = (unit: string) => ({ divisor: 10, decimals: 1, unit }); +const whole = (unit: string) => ({ divisor: 1, decimals: 0, unit }); + +const VIBRATION: Field[] = [ + { key: 'vn', label: 'Fan Vibration, N1 Track (VN)', unscaled: true }, + { + key: 'vl', + label: 'Turbine Rear Frame Vibration, N1 Track (VL)', + unscaled: true, + }, + { key: 'pha', label: 'Fan Phase Angle (PHA)', scale: whole(' degrees') }, + { + key: 'pht', + label: 'Turbine Rear Frame Phase Angle (PHT)', + scale: whole(' degrees'), + }, +]; + +const GROUPS: Group[] = [ + { + blocks: ['CE', 'CN'], + fields: [ + { key: 'tat', label: 'Total Air Temperature', scale: tenths(' C') }, + { + key: 'altitude', + label: 'Pressure Altitude', + scale: whole(' feet'), + first: true, + }, + { key: 'cas', label: 'Calibrated Airspeed', scale: whole(' knots') }, + { + key: 'mach', + label: 'Mach', + scale: { divisor: 1000, decimals: 3, unit: '' }, + first: true, + }, + { key: 'gross_weight', label: 'Gross Weight', first: true }, + { key: 'cg', label: 'Centre of Gravity (CG)', unscaled: true }, + { key: 'dmu_software', label: 'DMU Software (DMU/SW)' }, + ], + }, + { + blocks: ['EC', 'EE'], + cfm: true, + fields: [ + { key: 'engine_serial', label: 'Engine Serial Number' }, + { + key: 'engine_hours', + label: 'Engine Flight Hours (EHRS)', + unscaled: true, + }, + { + key: 'engine_running_time', + label: 'Engine Running Time (ERT)', + unscaled: true, + }, + { key: 'engine_cycles', label: 'Engine Cycles (ECYC)', unscaled: true }, + // a constant 73 on every report seen, so not a live autopilot status + { key: 'ap', label: 'AP (meaning contested)' }, + { + key: 'quality_aircraft', + label: 'Aircraft Stability Quality (QA, 00 best)', + unscaled: true, + }, + { + key: 'quality_engine', + label: 'Engine Stability Quality (QE, 00 best)', + unscaled: true, + }, + ], + }, + { + blocks: ['N1', 'N2'], + cfm: true, + fields: [ + { key: 'n1', label: 'N1', scale: tenths(' %') }, + { key: 'n1_command', label: 'N1 Command', scale: tenths(' %') }, + { key: 'n2', label: 'N2', scale: tenths(' %') }, + { key: 'egt', label: 'EGT', scale: tenths(' C') }, + { key: 'fuel_flow', label: 'Fuel Flow' }, + { key: 'ps13', label: 'PS13 Static Pressure', unscaled: true }, + ], + }, + { + blocks: ['S1', 'S2'], + cfm: true, + fields: [ + { key: 'p25', label: 'P25 Compressor Pressure', unscaled: true }, + { key: 't25', label: 'T25 Compressor Temperature', unscaled: true }, + { key: 'p3', label: 'P3 Compressor Discharge Pressure', unscaled: true }, + { + key: 't3', + label: 'T3 Compressor Discharge Temperature', + unscaled: true, + }, + { key: 't5', label: 'T5 Station 5 Temperature', unscaled: true }, + { + key: 'vsv', + label: 'Variable Stator Vane Position (VSV)', + unscaled: true, + }, + { + key: 'vbv', + label: 'Variable Bleed Valve Position (VBV)', + unscaled: true, + }, + ], + }, + { + blocks: ['T1', 'T2'], + cfm: true, + fields: [ + { + key: 'hpt_clearance', + label: 'HPT Clearance Control', + scale: whole(' %'), + }, + { + key: 'lpt_clearance', + label: 'LPT Clearance Control', + scale: whole(' %'), + }, + { key: 'generator_load', label: 'Generator Load', scale: whole(' %') }, + { + key: 'precooler_pressure', + label: 'Precooler Inlet Pressure', + scale: whole(' psi'), + }, + { + key: 'nacelle_temperature', + label: 'Nacelle Temperature', + scale: whole(' C'), + }, + { key: 'pt2', label: 'PT2 Total Pressure', unscaled: true }, + { + key: 'oil_consumption', + label: 'Oil Consumption (OIQH)', + unscaled: true, + }, + ], + }, + { + blocks: ['V1', 'V2'], + cfm: true, + fields: [ + ...VIBRATION, + { key: 'vc', label: 'Fan Vibration, N2 Track (VC)', unscaled: true }, + { + key: 'vh', + label: 'Turbine Rear Frame Vibration, N2 Track (VH)', + unscaled: true, + }, + { key: 'evm', label: 'Vibration Monitoring Status (EVM)' }, + ], + }, + { + // from the last stable descent: N1 61.7-62.1 %, against 91.5-93.1 % in V5/V6 + blocks: ['V3', 'V4'], + cfm: true, + fields: [ + ...VIBRATION.map((f) => ({ + ...f, + key: `descent_${f.key}`, + label: `Descent ${f.label}`, + })), + { key: 'descent_n1', label: 'Descent N1', scale: tenths(' %') }, + ], + }, + { + // from the last stable climb + blocks: ['V5', 'V6'], + cfm: true, + fields: [ + ...VIBRATION.map((f) => ({ + ...f, + key: `climb_${f.key}`, + label: `Climb ${f.label}`, + })), + { key: 'climb_n1', label: 'Climb N1', scale: tenths(' %') }, + ], + }, + { + blocks: ['V7', 'V8'], + cfm: true, + fields: [ + { key: 'oil_pressure', label: 'Oil Pressure (OIP)', unscaled: true }, + { + key: 'oil_temperature', + label: 'Oil Temperature (OIT)', + unscaled: true, + }, + { key: 'ecw1', label: 'Engine Control Word 1 (ECW1, hex)' }, + { key: 'ssel', label: 'Sensor Selection (SSEL)' }, + ], + }, + { + blocks: ['X1', 'X2'], + fields: [ + // kilograms; X2 is above X1 on every report seen, so which is left and which right is + // not established + { + key: 'wing_fuel', + label: 'Inner Wing Fuel Quantity (WFQ, X1 / X2)', + scale: whole(' kg'), + }, + { key: 'elevator', label: 'Elevator Position', unscaled: true }, + { key: 'aoa', label: 'Angle of Attack', unscaled: true }, + { key: 'sideslip', label: 'Sideslip Angle', unscaled: true }, + { + key: 'flight_path_acceleration', + label: 'Flight Path Acceleration (CFPG)', + unscaled: true, + }, + { + key: 'inertial_vertical_speed', + label: 'Inertial Vertical Speed (CIVV)', + unscaled: true, + }, + ], + }, + { + blocks: ['X3'], + fields: [ + { key: 'rudder', label: 'Rudder Position', unscaled: true }, + { key: 'rudder_trim', label: 'Rudder Trim', unscaled: true }, + { key: 'aileron_left', label: 'Left Aileron', unscaled: true }, + { key: 'aileron_right', label: 'Right Aileron', unscaled: true }, + { key: 'stabilizer', label: 'Horizontal Stabilizer', unscaled: true }, + { key: 'roll', label: 'Roll Attitude', unscaled: true }, + { key: 'yaw', label: 'Yaw Rate', unscaled: true }, + ], + }, + { + blocks: ['X4', 'X5'], + fields: [ + { key: 'roll_spoiler_2', label: 'Roll Spoiler 2', unscaled: true }, + { key: 'roll_spoiler_3', label: 'Roll Spoiler 3', unscaled: true }, + { key: 'roll_spoiler_4', label: 'Roll Spoiler 4', unscaled: true }, + { key: 'roll_spoiler_5', label: 'Roll Spoiler 5', unscaled: true }, + { key: 'flap', label: 'Flap Position', unscaled: true }, + { key: 'slat', label: 'Slat Position', unscaled: true }, + ], + }, + { + blocks: ['X6', 'X7'], + fields: [ + { key: 'true_heading', label: 'True Heading', scale: tenths(' degrees') }, + { key: 'longitude', label: 'Longitude' }, + { key: 'latitude', label: 'Latitude' }, + { key: 'wind_speed', label: 'Wind Speed', scale: whole(' knots') }, + { + key: 'wind_direction', + label: 'Wind Direction', + scale: whole(' degrees'), + }, + { + key: 'fuel_temperature', + label: 'Fuel Temperature', + scale: tenths(' C'), + }, + { key: 'fuel_density', label: 'Fuel Density (FD)', unscaled: true }, + ], + }, +]; + +// unit configuration (C1 field 4): gross weight and fuel flow units +const POUNDS = ['09', '11']; +const KILOGRAMS = ['42', '45', '46']; + +const NOT_AVAILABLE = /^[X-]+$/; +const NUMBER = /^[N-]?\d+$/; +const BLOCK = /^(CC|C1|CE|CN|EC|EE|N1|N2|S1|S2|T1|T2|V[1-8]|X[1-7])(.*)$/; + +const signed = (value: string) => + /^[N-]/.test(value) ? -Number(value.slice(1)) : Number(value); + +/** + * Airbus ACMS cruise performance report <02> (A02), sent on H1 by A319, A320 and A321s + * (AMM 31-37-73). A header, then `/`-separated blocks: CC flight, C1 report codes, CE/CN air + * data, and per-engine pairs EC/EE, N1/N2, S1/S2, T1/T2, V1-V8, then aircraft X1-X7. The engine + * blocks follow the CFMI Advanced EVMU layout; aircraft with IAE engines send them in another + * layout, shown as sent. Field names and order follow the AMM, checked against 11 reports from + * at least 7 operators; a value is scaled only where its scaling is settled, and every other + * value is shown as sent. + */ +export class Label_H1_A02 extends DecoderPlugin { + name = 'label-h1-a02'; + + qualifiers() { + return { + labels: ['H1'], + preambles: ['A02/A'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + const decodeResult = this.initResult(message, 'Cruise Performance Report'); + + const text = message.text.replace(/\r?\n/g, ''); + const parts = text.split('/'); + // A02/A31902,1,1: aircraft type, report number again, page and pages + const header = parts[1]?.match(/^A(\d{3})02,(\d+),(\d+)$/); + if (parts[0] !== 'A02' || !header) { + return this.failUnknown(decodeResult, message.text, options); + } + ResultFormatter.raw( + decodeResult, + 'aircraft_type', + 'ACTYPE', + 'Aircraft Type', + `A${header[1]}`, + ); + ResultFormatter.raw( + decodeResult, + 'page', + 'PAGE', + 'Page', + `${header[2]} of ${header[3]}`, + Number(header[2]), + ); + + // a complete report ends with '/'; a copy of the first block alone is cut short + const cut = !text.endsWith('/'); + const blocks: Record = {}; + const unknown: string[] = []; + parts.slice(2).forEach((part, i, all) => { + if (part === '') return; + const m = part.match(BLOCK); + if (!m || (cut && i === all.length - 1)) { + unknown.push(part); + return; + } + const fields = m[2].split(','); + if (fields[fields.length - 1] === '') fields.pop(); + blocks[m[1]] = fields; + }); + + this.flight(decodeResult, blocks['CC']); + const config = this.codes(decodeResult, blocks['C1']); + const iae = blocks['CE']?.[6]?.startsWith('I') ?? false; + + for (const group of GROUPS) { + const present = group.blocks.filter((b) => blocks[b]); + if (present.length === 0) continue; + if (group.cfm && iae) { + for (const b of present) { + ResultFormatter.raw( + decodeResult, + `iae_${b.toLowerCase()}`, + b, + `${b} (IAE layout, unscaled)`, + blocks[b].join(','), + ); + } + continue; + } + const width = Math.max(...present.map((b) => blocks[b].length)); + for (let i = 0; i < width; i++) { + const field = group.fields[i]; + const values = present + .map((b) => blocks[b][i]) + .filter((v) => v !== undefined); + if (!field) { + unknown.push(values.join(',')); + continue; + } + if (field.key === 'gross_weight' || field.key === 'fuel_flow') { + this.unitField(decodeResult, field, values, config); + } else if (field.key === 'longitude' || field.key === 'latitude') { + // the pair of position fields is shown once, as a position + if (field.key === 'latitude') + this.position(decodeResult, present, blocks); + } else { + this.field(decodeResult, field, values); + } + } + } + + if (unknown.length > 0) + ResultFormatter.unknownArr(decodeResult, unknown, '/'); + this.setDecodeLevel(decodeResult, true); + return decodeResult; + } + + private flight(decodeResult: DecodeResult, cc: string[] | undefined) { + if (!cc) return; + // registration (some send it after a '.'), date (no year), time, origin, destination, flight + const [registration, date, time, origin, destination, flight] = cc; + if (registration) + ResultFormatter.tail(decodeResult, registration.replace(/^\.+/, '')); + if (date) + ResultFormatter.raw( + decodeResult, + 'report_date', + 'DATE', + 'Report Date', + date, + ); + if (time && /^\d{6}$/.test(time)) { + ResultFormatter.raw( + decodeResult, + 'report_time', + 'TIME', + 'Report Time', + `${time.slice(0, 2)}:${time.slice(2, 4)}:${time.slice(4, 6)}`, + time, + ); + } else if (time) { + ResultFormatter.unknown(decodeResult, time, '/'); + } + if (origin) ResultFormatter.departureAirport(decodeResult, origin); + if (destination) ResultFormatter.arrivalAirport(decodeResult, destination); + if (flight) ResultFormatter.flightNumber(decodeResult, flight); + } + + /** C1: flight phase, CNT, trigger code, unit configuration, bleed and APU flags */ + private codes( + decodeResult: DecodeResult, + c1: string[] | undefined, + ): string | undefined { + if (!c1) return undefined; + const [phase, cnt, code, config, ...bleed] = c1; + if (phase) + ResultFormatter.raw( + decodeResult, + 'flight_phase', + 'PH', + 'Flight Phase (PH)', + phase, + ); + // constant on unrelated aircraft, so not the report counter it is documented as + if (cnt) + ResultFormatter.raw( + decodeResult, + 'cnt', + 'CNT', + 'CNT (not a report counter)', + cnt, + ); + if (code) + ResultFormatter.raw( + decodeResult, + 'trigger_code', + 'CODE', + 'Report Trigger Code', + code, + ); + if (config) { + const units = POUNDS.includes(config) + ? 'pounds' + : KILOGRAMS.includes(config) + ? 'kilograms' + : 'units not sent'; + ResultFormatter.raw( + decodeResult, + 'unit_config', + 'UNITS', + 'Unit Configuration', + `${config} (${units})`, + config, + ); + } + if (bleed.length > 0) { + ResultFormatter.raw( + decodeResult, + 'bleed_apu', + 'BLEED', + 'Bleed Status and APU', + bleed.join(','), + ); + } + return config; + } + + private field(decodeResult: DecodeResult, field: Field, values: string[]) { + const shown = values.map((v) => this.value(v, field)); + ResultFormatter.raw( + decodeResult, + field.key, + field.key.toUpperCase(), + field.unscaled ? `${field.label} (unscaled)` : field.label, + shown.map((s) => s.text).join(' / '), + shown.length === 1 || field.first + ? shown[0].value + : shown.map((s) => s.value), + ); + } + + private value(value: string, field: Field): { text: string; value: unknown } { + if (NOT_AVAILABLE.test(value)) + return { text: 'not available', value: undefined }; + const scale = field.scale; + if (!scale || !NUMBER.test(value)) return { text: value, value }; + const n = (signed(value) * (scale.multiplier ?? 1)) / (scale.divisor ?? 1); + return { text: `${n.toFixed(scale.decimals)}${scale.unit}`, value: n }; + } + + /** + * Gross weight (x100 lb or x10 kg) and fuel flow per engine (x10 lb/h or x1 kg/h): the unit + * configuration picks the units. Without a known one the values are shown as sent. + */ + private unitField( + decodeResult: DecodeResult, + field: Field, + values: string[], + config: string | undefined, + ) { + const weight = field.key === 'gross_weight'; + const scale: Scale | undefined = POUNDS.includes(config ?? '') + ? weight + ? { multiplier: 100, decimals: 0, unit: ' lb' } + : { multiplier: 10, decimals: 0, unit: ' lb/h' } + : KILOGRAMS.includes(config ?? '') + ? weight + ? { multiplier: 10, decimals: 0, unit: ' kg' } + : { decimals: 0, unit: ' kg/h' } + : undefined; + if (scale) { + this.field(decodeResult, { ...field, scale }, values); + } else { + this.field( + decodeResult, + { + ...field, + label: `${field.label} (unscaled, unit configuration not sent)`, + }, + values, + ); + } + } + + /** X6/X7 fields 2 and 3: longitude W0822 and latitude N410, tenths of a degree */ + private position( + decodeResult: DecodeResult, + present: string[], + blocks: Record, + ) { + const positions = present.map((b) => { + const lon = blocks[b][1]?.match(/^([EW])(\d{4})$/); + const lat = blocks[b][2]?.match(/^([NS])(\d{3})$/); + if (!lon || !lat) return undefined; + return { + latitude: (lat[1] === 'S' ? -1 : 1) * (Number(lat[2]) / 10), + longitude: (lon[1] === 'W' ? -1 : 1) * (Number(lon[2]) / 10), + }; + }); + if (positions.some((p) => p === undefined)) { + ResultFormatter.unknown( + decodeResult, + present.map((b) => `${blocks[b][1]},${blocks[b][2]}`).join(','), + '/', + ); + return; + } + const valid = positions as { latitude: number; longitude: number }[]; + ResultFormatter.raw( + decodeResult, + 'position', + 'POS', + 'Aircraft Position', + valid.map((p) => CoordinateUtils.coordinateString(p)).join(' / '), + valid[0], + ); + } +} diff --git a/lib/plugins/official.ts b/lib/plugins/official.ts index 469312a7..bdbd4242 100644 --- a/lib/plugins/official.ts +++ b/lib/plugins/official.ts @@ -52,6 +52,7 @@ export * from './Label_8E'; export * from './Label_B6'; export * from './Label_ColonComma'; export * from './Label_H1_76401'; +export * from './Label_H1_A02'; export * from './Label_H1_ATIS'; export * from './Label_H1_EZF'; export * from './Label_H1_FLR'; From 74e1eae7f3fedeebcfaf31153c45149d1e212d09 Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:25:01 -0400 Subject: [PATCH 2/2] fix(plugins): guard A02 blocks, time and position, per review A block sent twice keeps its first copy and the repeat goes to the remaining text; a header with no blocks is not decoded; the report time must be a valid HHMMSS; a position beyond 90 degrees of latitude or 180 of longitude goes to the remaining text. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_H1_A02.test.ts | 24 ++++++++++++++++ lib/plugins/Label_H1_A02.ts | 49 ++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/lib/plugins/Label_H1_A02.test.ts b/lib/plugins/Label_H1_A02.test.ts index 025cb68f..e2322b2a 100644 --- a/lib/plugins/Label_H1_A02.test.ts +++ b/lib/plugins/Label_H1_A02.test.ts @@ -163,4 +163,28 @@ describe('Label H1 A02', () => { expect(decodeResult.decoded).toBe(false); expect(decodeResult.decoder.decodeLevel).toBe('none'); }); + + test('does not decode a header alone', () => { + message.text = 'A02/A31902,1,1/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); + + test('leaves a repeated block, a bad time and a bad position undecoded', () => { + message.text = + 'A02/A32102,1,1/CCN157UW,OCT18,256148,MMSD,KORD,2636/CCN999XX,OCT18,210148,KAAA,KBBB,0001/X61386,W0822,N999,073,289,0169,XXXX/'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.tail).toBe('N157UW'); + expect(decodeResult.raw.report_time).toBeUndefined(); + expect(decodeResult.raw.position).toBeUndefined(); + expect(decodeResult.raw.true_heading).toBe(138.6); + expect(decodeResult.remaining.text).toBe( + '256148/W0822,N999/CCN999XX,OCT18,210148,KAAA,KBBB,0001', + ); + }); }); diff --git a/lib/plugins/Label_H1_A02.ts b/lib/plugins/Label_H1_A02.ts index 0ac7c53d..580f2c3b 100644 --- a/lib/plugins/Label_H1_A02.ts +++ b/lib/plugins/Label_H1_A02.ts @@ -336,21 +336,6 @@ export class Label_H1_A02 extends DecoderPlugin { if (parts[0] !== 'A02' || !header) { return this.failUnknown(decodeResult, message.text, options); } - ResultFormatter.raw( - decodeResult, - 'aircraft_type', - 'ACTYPE', - 'Aircraft Type', - `A${header[1]}`, - ); - ResultFormatter.raw( - decodeResult, - 'page', - 'PAGE', - 'Page', - `${header[2]} of ${header[3]}`, - Number(header[2]), - ); // a complete report ends with '/'; a copy of the first block alone is cut short const cut = !text.endsWith('/'); @@ -359,7 +344,8 @@ export class Label_H1_A02 extends DecoderPlugin { parts.slice(2).forEach((part, i, all) => { if (part === '') return; const m = part.match(BLOCK); - if (!m || (cut && i === all.length - 1)) { + // a block sent twice keeps its first copy + if (!m || (cut && i === all.length - 1) || blocks[m[1]]) { unknown.push(part); return; } @@ -367,6 +353,25 @@ export class Label_H1_A02 extends DecoderPlugin { if (fields[fields.length - 1] === '') fields.pop(); blocks[m[1]] = fields; }); + if (Object.keys(blocks).length === 0 && unknown.length === 0) { + return this.failUnknown(decodeResult, message.text, options); + } + + ResultFormatter.raw( + decodeResult, + 'aircraft_type', + 'ACTYPE', + 'Aircraft Type', + `A${header[1]}`, + ); + ResultFormatter.raw( + decodeResult, + 'page', + 'PAGE', + 'Page', + `${header[2]} of ${header[3]}`, + Number(header[2]), + ); this.flight(decodeResult, blocks['CC']); const config = this.codes(decodeResult, blocks['C1']); @@ -429,7 +434,7 @@ export class Label_H1_A02 extends DecoderPlugin { 'Report Date', date, ); - if (time && /^\d{6}$/.test(time)) { + if (time && /^([01]\d|2[0-3])[0-5]\d[0-5]\d$/.test(time)) { ResultFormatter.raw( decodeResult, 'report_time', @@ -572,10 +577,12 @@ export class Label_H1_A02 extends DecoderPlugin { const lon = blocks[b][1]?.match(/^([EW])(\d{4})$/); const lat = blocks[b][2]?.match(/^([NS])(\d{3})$/); if (!lon || !lat) return undefined; - return { - latitude: (lat[1] === 'S' ? -1 : 1) * (Number(lat[2]) / 10), - longitude: (lon[1] === 'W' ? -1 : 1) * (Number(lon[2]) / 10), - }; + const latitude = (lat[1] === 'S' ? -1 : 1) * (Number(lat[2]) / 10); + const longitude = (lon[1] === 'W' ? -1 : 1) * (Number(lon[2]) / 10); + if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { + return undefined; + } + return { latitude, longitude }; }); if (positions.some((p) => p === undefined)) { ResultFormatter.unknown(