From a17c1c13c8183cf848f72a4853ed32a214f6b79d Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:37:39 -0400 Subject: [PATCH 1/3] feat(plugins): add Southwest label 37 and 40 decoders Southwest compresses its label 37 downlinks and label 40 uplinks with nine substitution tables per label, picked by a two-digit code. The new plugins decode the text of every type and read the fields of the laid-out ones (position, climb, ETA, gate request, door, engine/fuel, maintenance, frequency handoff, approach stability, gate assignment). A message with an unread character or a field outside its type's layout is shown as text only. PIC01 uplinks (duty-extension requests with crew employee numbers) are not decoded. Co-Authored-By: Claude Opus 5.5 --- lib/MessageDecoder.ts | 2 + lib/plugins/Label_37_SWA.test.ts | 196 +++++++++++++++++++ lib/plugins/Label_37_SWA.ts | 323 +++++++++++++++++++++++++++++++ lib/plugins/Label_40_SWA.test.ts | 111 +++++++++++ lib/plugins/Label_40_SWA.ts | 130 +++++++++++++ lib/plugins/official.ts | 2 + lib/utils/swa_compressed.ts | 106 ++++++++++ lib/utils/swa_fields.ts | 196 +++++++++++++++++++ 8 files changed, 1066 insertions(+) create mode 100644 lib/plugins/Label_37_SWA.test.ts create mode 100644 lib/plugins/Label_37_SWA.ts create mode 100644 lib/plugins/Label_40_SWA.test.ts create mode 100644 lib/plugins/Label_40_SWA.ts create mode 100644 lib/utils/swa_compressed.ts create mode 100644 lib/utils/swa_fields.ts diff --git a/lib/MessageDecoder.ts b/lib/MessageDecoder.ts index 4217d02..676fded 100644 --- a/lib/MessageDecoder.ts +++ b/lib/MessageDecoder.ts @@ -43,6 +43,8 @@ const pluginClasses = [ Plugins.Label_2P_FM4, Plugins.Label_2P_FM5, Plugins.Label_30_Slash_EA, + Plugins.Label_37_SWA, + Plugins.Label_40_SWA, Plugins.Label_44_ETA, Plugins.Label_44_IN, Plugins.Label_44_OFF, diff --git a/lib/plugins/Label_37_SWA.test.ts b/lib/plugins/Label_37_SWA.test.ts new file mode 100644 index 0000000..4dfd3b6 --- /dev/null +++ b/lib/plugins/Label_37_SWA.test.ts @@ -0,0 +1,196 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_37_SWA } from './Label_37_SWA'; + +describe('Label_37_SWA', () => { + let plugin: Label_37_SWA; + const message = { label: '37', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_37_SWA(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.name).toBe('label-37-swa'); + expect(plugin.qualifiers()).toEqual({ + labels: ['37'], + preambles: ['01', '02', '03', '04', '05', '06', '07', '08', '09'], + }); + }); + + test('decodes POS01 position report', () => { + // KMCI,KLAS,N 38.523,W100.807,37999,252,460,.797,15.4,2022,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.raw.departure_icao).toBe('KMCI'); + expect(decodeResult.raw.arrival_icao).toBe('KLAS'); + expect(decodeResult.raw.position).toEqual({ + latitude: 38.523, + longitude: -100.807, + }); + expect(decodeResult.raw.altitude).toBe(37999); + expect(decodeResult.raw.indicated_airspeed).toBe('252 knots'); + expect(decodeResult.raw.airspeed).toBe(460); + expect(decodeResult.raw.mach).toBe(0.797); + expect(decodeResult.raw.fuel_on_board).toBe(15400); + expect(decodeResult.raw.eta_time).toBe(20 * 3600 + 22 * 60); + expect(decodeResult.raw.track).toBe('259 degrees'); + expect(decodeResult.raw.wind).toBe('274 degrees at 60 knots'); + expect(decodeResult.raw.outside_air_temperature).toBe(-53); + expect(decodeResult.raw.gross_weight).toBe('142080 pounds'); + expect(decodeResult.formatted.items.length).toBe(13); + expect(decodeResult.remaining.text).toBeUndefined(); + }); + + test('decodes FHD04 frequency handoff report', () => { + // ...,-20,OFF,X,134440, 1,210150,130450,00000 + message.text = + '05/0.CE\nNOJMANO.TA3VECBG, ATVG,B)CCADZCC AD,GAEC,ABFFCAV)BFADZDGACFDADGCAC(EAXDCAP//A8AZ EEECAVVVZADZCZ(CAZ CE(CACCCCC'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.formatted.description).toBe('Frequency Handoff Report'); + expect(decodeResult.raw.departure_icao).toBe('KMCI'); + expect(decodeResult.raw.arrival_icao).toBe('KMDW'); + expect(decodeResult.raw.altitude).toBe(21003); + expect(decodeResult.raw.event).toBe('OFF'); + expect(decodeResult.raw.gross_weight).toBe('134440 pounds'); + expect(decodeResult.raw.frequency).toBe('130.450 MHz'); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe(' 1,210150,00000'); + }); + + test('decodes CLB02 climb report', () => { + // KMCO,KBDL,N 28.855,|W 81.289|17.3,1445 + message.text = '04PB(38\nS-PC2S(6B2TH85Y5ZZ2\nFH5 Y85:\n MYL2 44Z'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Climb Report'); + expect(decodeResult.raw.position).toEqual({ + latitude: 28.855, + longitude: -81.289, + }); + expect(decodeResult.raw.fuel_on_board).toBe(17300); + expect(decodeResult.raw.eta_time).toBe(14 * 3600 + 45 * 60); + }); + + test('decodes ETA03 ETA report', () => { + // KMDW,KLGA,0104,13.3,N 41.404,W 79.888 + message.text = '08JLHR:\nO()2GO6ZHGR RWG :0:G7FW 0WRWG2F450SSS'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('ETA Report'); + expect(decodeResult.raw.departure_icao).toBe('KMDW'); + expect(decodeResult.raw.arrival_icao).toBe('KLGA'); + expect(decodeResult.raw.eta_time).toBe(3600 + 4 * 60); + expect(decodeResult.raw.fuel_on_board).toBe(13300); + expect(decodeResult.raw.position).toEqual({ + latitude: 41.404, + longitude: -79.888, + }); + }); + + test('decodes TDM01 door report', () => { + // KPDX,KLAS,N 45.588,W122.586|L2,O,1019,033718 + message.text = '01GL.3N\n)4L:Z)EA6Z0I71M1,,ZHNOOM1,-\nEOZFZN3NUZ3PPSN,'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.formatted.description).toBe('Door Report'); + expect(decodeResult.raw.door_event).toEqual({ + door: 'L2 (aft entry)', + state: 'open', + }); + expect(decodeResult.remaining.text).toBe('1019,033718'); + }); + + test('decodes FCM01 engine and fuel state', () => { + // L1,R1,A1,B0,115,0449 + message.text = '07N16)G\nQGZTGZ0GZL)ZGGIZ)--K'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.formatted.description).toBe('Engine and Fuel State'); + expect(decodeResult.raw.left_engine).toBe('running'); + expect(decodeResult.raw.right_engine).toBe('running'); + expect(decodeResult.raw.apu).toBe('running'); + expect(decodeResult.raw.fuel_on_board).toBe(11500); + expect(decodeResult.remaining.text).toBe('B0,0449'); + }); + + test('decodes MXR03 maintenance report, nothing to report', () => { + // KBWI,2226,0| + message.text = '01.:/3P\n)JH(ZOOO-Z3\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Maintenance Report'); + expect(decodeResult.raw.arrival_icao).toBe('KBWI'); + expect(decodeResult.raw.eta_time).toBe(22 * 3600 + 26 * 60); + expect(decodeResult.raw.maintenance_code).toBe('0 (Nothing to report)'); + expect(decodeResult.raw.text).toBeUndefined(); + }); + + test('decodes FTD02 crew free text', () => { + // KATL,KDEN|JUST GOT CLEARED BACK IN| + message.text = '01WGL3O\n)AGEZ)LC0\nT86GIKFGIBECA/CLIJAB)I(0\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Free Text to Dispatch'); + expect(decodeResult.raw.departure_icao).toBe('KATL'); + expect(decodeResult.raw.arrival_icao).toBe('KDEN'); + expect(decodeResult.raw.text).toBe('JUST GOT CLEARED BACK IN\n'); + }); + + test('decodes VXR02 voice call request', () => { + message.text = '01 :/3O\nLAE'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Voice Call Request'); + expect(decodeResult.raw.station_iata).toBe('DAL'); + }); + + test('shows text only when a character is unread', () => { + message.text = '01 :/3O\n6g'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.text).toBe('S¤'); + expect(decodeResult.raw.station_iata).toBeUndefined(); + }); + + test('does not decode a body outside the SWA layout', () => { + message.text = '01ABCDE'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + expect(decodeResult.remaining.text).toBe('01ABCDE'); + }); + + test('is chosen by MessageDecoder for label 37', () => { + const decoder = new MessageDecoder(); + const decodeResult = decoder.decode({ + label: '37', + text: '08JLHR:\nO()2GO6ZHGR RWG :0:G7FW 0WRWG2F450SSS', + }); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.name).toBe('label-37-swa'); + }); +}); diff --git a/lib/plugins/Label_37_SWA.ts b/lib/plugins/Label_37_SWA.ts new file mode 100644 index 0000000..10ef39e --- /dev/null +++ b/lib/plugins/Label_37_SWA.ts @@ -0,0 +1,323 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; +import { + decodeSwaLabel37, + isSwaType, + SWA_UNREAD, +} from '../utils/swa_compressed'; +import { + SWA_LAYOUTS, + SWA_PREFIX_LAYOUTS, + SwaFieldWriter, + swaFields, + swaMatchesLayout, +} from '../utils/swa_fields'; + +const DESCRIPTIONS: Record = { + OUT03: 'Out Report', + TXI02: 'Taxi Report', + OFF02: 'Off Report', + ONN02: 'On Report', + INN03: 'In Report', + FLI01: 'Flight Initialization', + CLB02: 'Climb Report', + POS01: 'Position Report', + HGD03: 'HowGozit Report', + ETA03: 'ETA Report', + INR04: 'In Range Report', + GAM02: 'Gate Assignment Request', + MXR03: 'Maintenance Report', + FTD02: 'Free Text to Dispatch', + TRB02: 'Turbulence Report', + HLD01: 'Holding Report', + DVC01: 'Divert Plan (Crew Copy)', + FCR03: 'Runway Condition Report', + VXR02: 'Voice Call Request', + RTG04: 'Return to Gate Report', + RTG05: 'Return to Gate Report', + TDM01: 'Door Report', + FCM01: 'Engine and Fuel State', + RRR01: 'Reroute Report', + ADV01: 'Ride Conditions Report', + FHD04: 'Frequency Handoff Report', + PFR03: 'Post Flight Report', +}; + +const DOORS: Record = { + L1: 'L1 (forward entry)', + L2: 'L2 (aft entry)', + CR: 'Cargo', + SV: 'Service', +}; + +const MXR_CODES: Record = { + '0': 'Nothing to report', + '1': 'Status option', + '2': 'Category and text', + '3': 'Free text', +}; + +/** + * Southwest Airlines label 37 downlinks: a two-digit table code, the type name in that table on + * the first line, then the body in the same table. See utils/swa_compressed.ts. + */ +export class Label_37_SWA extends DecoderPlugin { + name = 'label-37-swa'; + + qualifiers() { + return { + labels: ['37'], + preambles: ['01', '02', '03', '04', '05', '06', '07', '08', '09'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + const decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.message = message; + + const d = decodeSwaLabel37(message.text); + if (!d || !isSwaType(d.type)) { + if (options.debug) { + console.log(`Decoder: Unknown 37 message: ${message.text}`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.formatted.description = 'Unknown'; + return decodeResult; + } + + decodeResult.decoded = true; + decodeResult.formatted.description = + DESCRIPTIONS[d.type] ?? 'Southwest Message'; + decodeResult.raw.preamble = d.type; + + const fields = swaFields(d.text); + const layouts = SWA_LAYOUTS[d.type]; + const prefix = SWA_PREFIX_LAYOUTS[d.type]; + const clean = !d.text.includes(SWA_UNREAD); + if (clean && layouts && swaMatchesLayout(fields, layouts)) { + const rest = this.writeFields(decodeResult, d.type, fields); + decodeResult.decoder.decodeLevel = rest === 0 ? 'full' : 'partial'; + return decodeResult; + } + if ( + clean && + prefix && + swaMatchesLayout(fields.slice(0, prefix.length), [prefix]) + ) { + this.writeMaintenance(decodeResult, d.text, fields); + decodeResult.decoder.decodeLevel = 'full'; + return decodeResult; + } + + if (clean && !layouts) { + // Free text and the report types whose fields are not read: the text in full. + const lines = d.text.split('|'); + const route = lines[0].match(/^([A-Z]{4}),([A-Z]{4})$/); + if (route && d.type === 'FTD02') { + ResultFormatter.departureAirport(decodeResult, route[1]); + ResultFormatter.arrivalAirport(decodeResult, route[2]); + lines.shift(); + } + ResultFormatter.text(decodeResult, lines.join('\n')); + decodeResult.decoder.decodeLevel = 'full'; + return decodeResult; + } + + // An unread character or a field that breaks the type's layout: show the text only. + ResultFormatter.text(decodeResult, d.text.split('|').join('\n')); + decodeResult.decoder.decodeLevel = 'partial'; + return decodeResult; + } + + /** Writes a laid-out type's fields; returns how many went to `remaining`. */ + private writeFields( + decodeResult: DecodeResult, + type: string, + fields: string[], + ): number { + const w = new SwaFieldWriter(decodeResult, fields); + switch (type) { + case 'POS01': + case 'HGD03': + case 'FHD04': { + // from, to, lat, lon, alt, IAS, TAS, Mach, FOB, ETA, track, wind dir, wind kt, SAT, gross wt + w.departure(0); + w.arrival(1); + w.position(2, 3); + w.altitude(4); + const ias = w.int(5); + if (ias !== undefined) { + w.item( + 5, + 'indicated_airspeed', + 'IAS', + 'Indicated Airspeed', + `${ias} knots`, + ); + } + const tas = w.int(6); + if (tas !== undefined) ResultFormatter.airspeed(decodeResult, tas); + ResultFormatter.mach(decodeResult, Number(w.use(7))); + w.fuel(8); + w.eta(9); + const track = w.int(10); + if (track !== undefined) { + w.item(10, 'track', 'TRK', 'Track', `${track} degrees`); + } + const windDir = w.int(11); + const windSpeed = w.int(12); + if (windDir !== undefined && windSpeed !== undefined) { + w.item( + 11, + 'wind', + 'WIND', + 'Wind', + `${windDir} degrees at ${windSpeed} knots`, + ); + } + const sat = w.int(13); + if (sat !== undefined) + ResultFormatter.temperature(decodeResult, `${sat}`); + // FHD04 has the first 14 fields, then event, X, gross wt, digit, snapshot HHMMSS, + // frequency in use, 00000. + const gwIndex = type === 'FHD04' ? 16 : 14; + const gw = w.int(gwIndex); + if (gw !== undefined) { + w.item( + gwIndex, + 'gross_weight', + 'GWT', + 'Gross Weight', + `${gw} pounds`, + ); + } + if (type === 'FHD04') { + w.item(14, 'event', 'EVT', 'Event', fields[14]); + w.use(15); + const freq = w.use(19); + if (/^1\d{5}$/.test(freq)) { + w.item( + 19, + 'frequency', + 'FREQ', + 'ACARS Frequency', + `${freq.substring(0, 3)}.${freq.substring(3)} MHz`, + ); + } + } + break; + } + case 'GAM02': + w.departure(0); + w.arrival(1); + w.eta(2); + w.position(3, 4); + break; + case 'CLB02': + w.departure(0); + w.arrival(1); + w.position(2, 4); + w.fuel(5); + w.eta(6); + break; + case 'ETA03': + w.departure(0); + w.arrival(1); + w.eta(2); + w.fuel(3); + w.position(4, 5); + break; + case 'INR04': + w.departure(0); + w.arrival(1); + w.fuel(4); + w.position(5, 6); + break; + case 'FLI01': + w.departure(0); + w.arrival(1); + w.fuel(3); + w.position(4, 5); + break; + case 'OUT03': + case 'TXI02': + w.departure(0); + w.arrival(1); + w.fuel(3); + break; + case 'INN03': + w.departure(0); + w.arrival(1); + w.fuel(4); + break; + case 'RTG04': + case 'RTG05': + w.departure(0); + w.arrival(1); + w.fuel(5); + break; + case 'TDM01': + w.departure(0); + w.arrival(1); + w.position(2, 3); + ResultFormatter.door_event( + decodeResult, + DOORS[w.use(4)], + w.use(5) === 'O' ? 'open' : 'closed', + ); + break; + case 'FCM01': { + // L, R (engines), A (APU), each 1 running / 0 off; fuel in hundreds of pounds + const names: Record = { + L: 'Left Engine', + R: 'Right Engine', + A: 'APU', + }; + for (let i = 0; i < 3; i++) { + const v = fields[i]; + if (v[1] === '0' || v[1] === '1') { + w.item( + i, + `${names[v[0]].toLowerCase().replace(' ', '_')}`, + v[0], + names[v[0]], + v[1] === '1' ? 'running' : 'off', + ); + } + } + ResultFormatter.currentFuel(decodeResult, Number(w.use(4)) * 100); + break; + } + case 'VXR02': + w.item(0, 'station_iata', 'STN', 'Station', fields[0]); + break; + } + return w.finish(); + } + + /** MXR03: destination, ETA, code, then the category and text. */ + private writeMaintenance( + decodeResult: DecodeResult, + text: string, + fields: string[], + ) { + const w = new SwaFieldWriter(decodeResult, fields.slice(0, 3)); + w.arrival(0); + w.eta(1); + w.item( + 2, + 'maintenance_code', + 'MXCODE', + 'Report Code', + `${fields[2]} (${MXR_CODES[fields[2]]})`, + ); + // Skip the three header fields (the separators may be ',' or a line break). + const header = text.match(/^[^,|]*[,|][^,|]*[,|][^,|]*[,|]?/); + const body = text.substring(header ? header[0].length : 0); + if (body.trim() !== '') { + ResultFormatter.text(decodeResult, body.split('|').join('\n')); + } + } +} diff --git a/lib/plugins/Label_40_SWA.test.ts b/lib/plugins/Label_40_SWA.test.ts new file mode 100644 index 0000000..e609fd9 --- /dev/null +++ b/lib/plugins/Label_40_SWA.test.ts @@ -0,0 +1,111 @@ +import { MessageDecoder } from '../MessageDecoder'; +import { Label_40_SWA } from './Label_40_SWA'; + +describe('Label_40_SWA', () => { + let plugin: Label_40_SWA; + const message = { label: '40', text: '' }; + + beforeEach(() => { + const decoder = new MessageDecoder(); + plugin = new Label_40_SWA(decoder); + }); + + test('matches qualifiers', () => { + expect(plugin.name).toBe('label-40-swa'); + expect(plugin.qualifiers()).toEqual({ labels: ['40'] }); + }); + + test('decodes ASR01 approach stability report', () => { + // 1675,148,137, 979, 0.0,-0.8, 5.4, 9.2|| + message.text = 'ASR01,08OLN9SO7RSOZNS15N5S1.J.S/.JRS19J7S15JW\n\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe( + 'Approach Stability Report', + ); + expect(decodeResult.formatted.items.length).toBe(8); + expect(decodeResult.raw.reverser_distance).toBe('1675'); + expect(decodeResult.raw.max_ias).toBe('148'); + expect(decodeResult.raw.min_ias).toBe('137'); + expect(decodeResult.raw.touchdown_distance).toBe('979'); + expect(decodeResult.raw.glideslope_above).toBe('0.0'); + expect(decodeResult.raw.glideslope_below).toBe('-0.8'); + expect(decodeResult.raw.max_pitch).toBe('5.4'); + expect(decodeResult.raw.tailstrike_pitch).toBe('9.2'); + }); + + test('decodes ASR01 with no glideslope flown', () => { + // 1781,143,132, 995,XX.X,XX.X, 4.7,12.3|| + message.text = 'ASR01,02QVTQKQ51KQ1YKHNNWK))L)K))L)KH5LVKQYL1\n\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.items.length).toBe(6); + expect(decodeResult.raw.glideslope_above).toBeUndefined(); + expect(decodeResult.raw.tailstrike_pitch).toBe('12.3'); + }); + + test('decodes GAR03 gate assignment response', () => { + // C45 ,N,N,Y,N,N,129.300,| + message.text = 'GAR03,01EPZV81818/818185-YM0NN8\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.formatted.description).toBe('Gate Assignment Response'); + expect(decodeResult.raw.gate).toBe('C45'); + expect(decodeResult.raw.ramp_frequency).toBe('129.300 MHz'); + expect(decodeResult.remaining.text).toBe('N,N,Y,N,N'); + }); + + test('decodes POS01 position report request', () => { + message.text = 'POS01,Y,05'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Position Report Request'); + expect(decodeResult.raw.report_interval).toBe(5); + }); + + test('decodes free text in each table', () => { + // FTX01 table 07: raw L is Q. + message.text = 'FTX01,07L'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Dispatch Free Text'); + expect(decodeResult.raw.text).toBe('Q'); + }); + + test('does not decode PIC01', () => { + message.text = 'PIC01,01ABC'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.formatted.items.length).toBe(0); + }); + + test('does not decode a body outside the SWA layout', () => { + message.text = 'HELLO WORLD'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); + + test('is chosen by MessageDecoder for label 40', () => { + const decoder = new MessageDecoder(); + const decodeResult = decoder.decode({ + label: '40', + text: 'ASR01,08OLN9SO7RSOZNS15N5S1.J.S/.JRS19J7S15JW\n\n', + }); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.name).toBe('label-40-swa'); + }); +}); diff --git a/lib/plugins/Label_40_SWA.ts b/lib/plugins/Label_40_SWA.ts new file mode 100644 index 0000000..291527d --- /dev/null +++ b/lib/plugins/Label_40_SWA.ts @@ -0,0 +1,130 @@ +import { DecoderPlugin } from '../DecoderPlugin'; +import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; +import { ResultFormatter } from '../utils/result_formatter'; +import { + decodeSwaLabel40, + isSwaType, + SWA_UNREAD, +} from '../utils/swa_compressed'; +import { + SWA_LAYOUTS, + SwaFieldWriter, + swaFields, + swaMatchesLayout, +} from '../utils/swa_fields'; + +const DESCRIPTIONS: Record = { + FTX01: 'Dispatch Free Text', + GEN01: 'Company Message', + WFC01: 'Field Condition Report', + DVP01: 'Divert Plan', + ASR01: 'Approach Stability Report', + GAR03: 'Gate Assignment Response', + POS01: 'Position Report Request', +}; + +// ASR01 fields, in order. +const ASR01: [string, string, string][] = [ + [ + 'reverser_distance', + 'REVDIST', + 'Threshold to Thrust Reverser Deployment (feet)', + ], + ['max_ias', 'MAXIAS', 'Highest IAS, 1000 feet to Touchdown (knots)'], + ['min_ias', 'MINIAS', 'Lowest IAS, to Touchdown (knots)'], + ['touchdown_distance', 'TDDIST', 'Threshold to Touchdown (feet)'], + ['glideslope_above', 'GSHIGH', 'Largest Deviation Above Glideslope (dots)'], + ['glideslope_below', 'GSLOW', 'Largest Deviation Below Glideslope (dots)'], + ['max_pitch', 'MAXPITCH', 'Highest Pitch, Flare and Landing (degrees)'], + ['tailstrike_pitch', 'TSPITCH', 'Tail Strike Pitch Limit (degrees)'], +]; + +/** + * Southwest Airlines label 40 uplinks: `TYPE,` in clear, a two-digit table code, then the body in + * that table. See utils/swa_compressed.ts. + */ +export class Label_40_SWA extends DecoderPlugin { + name = 'label-40-swa'; + + qualifiers() { + return { + labels: ['40'], + }; + } + + decode(message: Message, options: Options = {}): DecodeResult { + const decodeResult = this.defaultResult(); + decodeResult.decoder.name = this.name; + decodeResult.message = message; + + const d = decodeSwaLabel40(message.text); + // PIC01 (duty-extension request) carries crew employee numbers; it is not decoded. + if (!d || !isSwaType(d.type) || d.type === 'PIC01') { + if (options.debug) { + console.log(`Decoder: Unknown 40 message: ${message.text}`); + } + ResultFormatter.unknown(decodeResult, message.text); + decodeResult.formatted.description = 'Unknown'; + return decodeResult; + } + + decodeResult.decoded = true; + decodeResult.formatted.description = + DESCRIPTIONS[d.type] ?? 'Southwest Message'; + decodeResult.raw.preamble = d.type; + + if (d.type === 'POS01') { + const m = d.text.match(/^POS01,Y,(\d{2})$/); + if (m) { + decodeResult.formatted.items.push({ + type: 'report_interval', + code: 'RPTINT', + label: 'Position Report Interval', + value: `${Number(m[1])} minutes`, + }); + decodeResult.raw.report_interval = Number(m[1]); + decodeResult.decoder.decodeLevel = 'full'; + } else { + ResultFormatter.unknown(decodeResult, d.text); + decodeResult.decoder.decodeLevel = 'partial'; + } + return decodeResult; + } + + const fields = swaFields(d.text); + const layouts = SWA_LAYOUTS[d.type]; + const clean = !d.text.includes(SWA_UNREAD); + if (clean && layouts && swaMatchesLayout(fields, layouts)) { + const w = new SwaFieldWriter(decodeResult, fields); + if (d.type === 'ASR01') { + ASR01.forEach(([key, code, label], i) => { + const v = fields[i].trim(); + // XX.X: no glideslope flown + if (v !== 'XX.X') w.item(i, key, code, label, v); + else w.use(i); + }); + } else if (d.type === 'GAR03') { + // gate, five Y/N flags (meaning not established), ramp frequency + w.item(0, 'gate', 'GATE', 'Gate', fields[0].trim()); + if (fields[6] !== '999.999') { + w.item( + 6, + 'ramp_frequency', + 'RAMPFREQ', + 'Ramp Frequency', + `${fields[6]} MHz`, + ); + } else { + w.use(6); + } + } + const rest = w.finish(); + decodeResult.decoder.decodeLevel = rest === 0 ? 'full' : 'partial'; + return decodeResult; + } + + ResultFormatter.text(decodeResult, d.text.split('|').join('\n')); + decodeResult.decoder.decodeLevel = clean && !layouts ? 'full' : 'partial'; + return decodeResult; + } +} diff --git a/lib/plugins/official.ts b/lib/plugins/official.ts index d55b539..469312a 100644 --- a/lib/plugins/official.ts +++ b/lib/plugins/official.ts @@ -29,6 +29,8 @@ export * from './Label_2P_FM4'; export * from './Label_2P_FM5'; export * from './Label_24_Slash'; export * from './Label_30_Slash_EA'; +export * from './Label_37_SWA'; +export * from './Label_40_SWA'; export * from './Label_44_ETA'; export * from './Label_44_IN'; export * from './Label_44_OFF'; diff --git a/lib/utils/swa_compressed.ts b/lib/utils/swa_compressed.ts new file mode 100644 index 0000000..5f27222 --- /dev/null +++ b/lib/utils/swa_compressed.ts @@ -0,0 +1,106 @@ +/** + * Southwest Airlines (SWA) compressed ACARS bodies, labels 37 (downlink) and 40 (uplink). + * + * Each body is written in one of nine substitution tables, picked by a two-digit code 01-09: + * label 37: <5-char type, in that table>\n + * label 40: , (the type name is in clear) + * + * All 18 tables are permutations of one 44-character set. Each label 40 table is the label 37 + * table with the same code read backwards. A few characters outside the set pass through as + * themselves; anything else is printed as UNREAD. + */ + +/** The raw characters, in the order the table strings below are written. */ +export const SWA_ALPHABET = ' (),-./0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +/** Printed for a raw character no table or passthrough covers (usually a reception error). */ +export const SWA_UNREAD = '¤'; + +/** Table code -> decoded character for each SWA_ALPHABET position. */ +export const SWA_LABEL_37_TABLES: Record = { + '01': 'VIK86MRN5-0PZS4UYXACEHLOTW BGD.123Q(7J9:F)/,', + '02': 'HG.KZL97QY15WRVTN4JA06(D ,:3MPBICU/8EOFSX)2-', + '03': '4GXE/QU5FT)20P8RSJ-169AWHLB.,37MNVCYODIKZ (:', + '04': '1BEHMPSUY,048DG269JLOQTWZ (-/37:ACFIKNRVX).5', + '05': '3579BDFHJLNPRTVXZ(,.02468:ACEGIKMOQSUWY )-/1', + '06': 'AGFEDCBRQPONMLKJIH:9876543210/.-,)( ZYXWVUTS', + '07': ':.02468ACEGIKMOQSUWY )-/13579BDFHJLNPRTVXZ(,', + '08': '1MDS/JQ.OWZ79LNR532-6(: ,APEFTVIKYB08H)U4CXG', + '09': '6LQMSR UZYVX(7:3-5KPTACB)021,894NODGJEFIHW./', +}; + +export const SWA_LABEL_40_TABLES: Record = { + '01': 'IRXZ2MY3NOP71-S,UVAJBLCWKD(T)E.0F4Q/6G8 H:95', + '02': 'GEXHZ)QC3YJ:4D0R/IBMOFSU( NA,.K9TL16V8P75W2-', + '03': 'XY3KAJ-5B4L 0CM7DZEIQT,1(GU:VHNOS6.892/PF)RW', + '04': 'HIX2JYK3 8L4Z9M5:NO(P6)Q7,RASB-TC.DU/E0VFW1G', + '05': 'V:WAXBYCZD E(F)G,HI-J.K/L0M1N2O3P4Q5R6S7T8U9', + '06': 'RQPONMLKJIHGFEDCBA /.-,)(:9876543210ZYXWVUTS', + '07': 'CYDZE(F)G,H-I.J/K 0L1M2N3O4P5Q6R7S8T9U:VAWBX', + '08': 'FDUGB0-R A:W9C4S5EHQX)JKZTN.O6(71I/8,LVM2YP3', + '09': '/5GK9YZHJI8N: 6LM7DFEQTURWVSA(,OPB).-C03X421', +}; + +// Raw characters outside the tables that are literal. Label 37 raw '>' reads as '+'. +const PASS_37: Record = { '*': '*', '>': '+' }; +const PASS_40: Record = { + '*': '*', + '?': '?', + "'": "'", + '#': '#', + _: '_', +}; + +/** A decoded body: the type name and the text, with line breaks as '|'. */ +export interface SwaDecoded { + type: string; + text: string; +} + +function lookup(table: string, c: string): string | undefined { + const i = SWA_ALPHABET.indexOf(c); + return i < 0 ? undefined : table[i]; +} + +function plain(table: string, body: string, pass: Record) { + let out = ''; + for (const c of body) { + if (c === '\n') out += '|'; + else out += lookup(table, c) ?? pass[c] ?? SWA_UNREAD; + } + return out; +} + +/** Decode a label 37 body, or return undefined if it is not in the SWA layout. */ +export function decodeSwaLabel37(body: string): SwaDecoded | undefined { + const b = body.replace(/\r/g, ''); + const table = SWA_LABEL_37_TABLES[b.substring(0, 2)]; + const nl = b.indexOf('\n'); + if (!table || nl < 0) return undefined; + let type = ''; + for (const c of b.substring(2, Math.min(nl, 7))) { + type += lookup(table, c) ?? SWA_UNREAD; + } + return { type, text: plain(table, b.substring(nl + 1), PASS_37) }; +} + +/** Decode a label 40 body, or return undefined if it is not in the SWA layout. */ +export function decodeSwaLabel40(body: string): SwaDecoded | undefined { + const b = body.replace(/\r/g, ''); + if (b.startsWith('POS01,')) { + // Not compressed: `POS01,Y,05` turns position reporting on, every 05 minutes. + return { type: 'POS01', text: b.trim() }; + } + const table = + b[5] === ',' ? SWA_LABEL_40_TABLES[b.substring(6, 8)] : undefined; + if (!table) return undefined; + return { + type: b.substring(0, 5), + text: plain(table, b.substring(8), PASS_40), + }; +} + +/** True for a type name in the SWA form, three letters and two digits (POS01, FTX01). */ +export function isSwaType(type: string): boolean { + return /^[A-Z]{3}\d{2}$/.test(type); +} diff --git a/lib/utils/swa_fields.ts b/lib/utils/swa_fields.ts new file mode 100644 index 0000000..bbe17df --- /dev/null +++ b/lib/utils/swa_fields.ts @@ -0,0 +1,196 @@ +import { DateTimeUtils } from '../DateTimeUtils'; +import { DecodeResult } from '../DecoderPluginInterface'; +import { ResultFormatter } from './result_formatter'; + +/** + * Field layouts and field writers shared by the Southwest label 37 and 40 plugins. + * + * A decoded body is split on ',' and on line breaks. A type's fields are only read when every + * field matches its layout; otherwise the plugin returns the decoded text alone. The "no value" + * forms (empty, ----.---, **.*, ****) are the aircraft's own placeholders. + */ +const APT = '(?:[A-Z]{4}| {4}|)'; +const LAT = '(?:[NS] ?\\d{1,2}\\.\\d{3}|-{4}\\.-{3}|)'; +const LON = '(?:[EW] ?\\d{1,3}\\.\\d{3}|-{4}\\.-{3}|)'; +const T4 = '(?:\\d{4}|\\*{4}|-{4})'; +const FOB = '(?: ?\\d{1,2}\\.\\d|\\*\\*\\.\\*)'; +const INT = '(?: *-? *\\d+| *|-+|\\*+)'; +const MACH = '\\.\\d{3}'; +const HMS = '\\d{6}'; +const OC = '[OC]'; +const POS = [ + APT, + APT, + LAT, + LON, + INT, + INT, + INT, + MACH, + FOB, + T4, + INT, + INT, + INT, + INT, +]; +const ASR = ' *-?\\d+(?:\\.[\\d-])?|XX\\.X'; + +/** Each type's layouts, one pattern per field. A type may have more than one layout. */ +export const SWA_LAYOUTS: Record = { + POS01: [ + [...POS, INT], + [...POS, INT, HMS], + ], + HGD03: [[...POS, INT]], + FHD04: [ + [ + ...POS, + '(?:OUT|OFF|ON|IN|RET IN)', + 'X', + INT, + ' *\\d*', + HMS, + '(?:\\d{6}|-{6})', + '\\d{5}', + ], + ], + GAM02: [[APT, APT, T4, LAT, LON]], + CLB02: [[APT, APT, LAT, '', LON, FOB, T4]], + ETA03: [[APT, APT, T4, FOB, LAT, LON]], + INR04: [[APT, APT, T4, T4, FOB, LAT, LON]], + FLI01: [[APT, APT, T4, FOB, LAT, LON]], + OUT03: [[APT, APT, T4, FOB, FOB, T4]], + TXI02: [[APT, APT, T4, FOB]], + INN03: [[APT, APT, T4, T4, FOB, OC, OC, OC, OC]], + RTG04: [[APT, APT, T4, T4, T4, FOB, OC, OC, OC, OC]], + RTG05: [[APT, APT, T4, T4, T4, FOB, OC, OC, OC, OC]], + TDM01: [[APT, APT, LAT, LON, '(?:L1|L2|CR|SV)', OC, T4, HMS]], + FCM01: [['L[\\d-]', 'R[\\d-]', 'A[\\d-]', 'B[\\d-]', '\\d+', T4]], + GAR03: [ + [ + '[A-Z0-9]{1,4} *', + '[YN]', + '[YN]', + '[YN]', + '[YN]', + '[YN]', + '(?:1[23]\\d\\.\\d{1,3}|999\\.999)', + '', + '', + ], + ], + ASR01: [[ASR, ASR, ASR, ASR, ASR, ASR, ASR, ASR, '', '']], + VXR02: [['[A-Z]{3}']], +}; + +/** Types whose first fields have a layout and whose free text follows. */ +export const SWA_PREFIX_LAYOUTS: Record = { + MXR03: [APT, '(?:\\d{4}|)', '[0-3]'], +}; + +export function swaFields(text: string): string[] { + return text.replace(/\|/g, ',').split(','); +} + +/** True when the fields match one of the layouts exactly. */ +export function swaMatchesLayout(fields: string[], layouts: string[][]) { + return layouts.some( + (layout) => + layout.length === fields.length && + layout.every((rx, i) => new RegExp(`^(?:${rx})$`).test(fields[i])), + ); +} + +/** + * Writes fields into a DecodeResult and remembers which ones it used, so the rest can go to + * `remaining`. + */ +export class SwaFieldWriter { + private used = new Set(); + + constructor( + private decodeResult: DecodeResult, + private fields: string[], + ) {} + + private take(i: number): string { + this.used.add(i); + return this.fields[i] ?? ''; + } + + /** A field with no ResultFormatter helper; `key` is its name in `raw` and its item type. */ + item(i: number, key: string, code: string, label: string, value: string) { + this.used.add(i); + this.decodeResult.raw[key] = value; + this.decodeResult.formatted.items.push({ type: key, code, label, value }); + } + + /** Marks a field as read without writing it (it was written together with another). */ + use(i: number): string { + return this.take(i); + } + + departure(i: number) { + const v = this.take(i); + if (/^[A-Z]{4}$/.test(v)) + ResultFormatter.departureAirport(this.decodeResult, v); + } + + arrival(i: number) { + const v = this.take(i); + if (/^[A-Z]{4}$/.test(v)) + ResultFormatter.arrivalAirport(this.decodeResult, v); + } + + position(latIndex: number, lonIndex: number) { + const lat = this.take(latIndex); + const lon = this.take(lonIndex); + if (!/^[NS] ?\d/.test(lat) || !/^[EW] ?\d/.test(lon)) return; + ResultFormatter.position(this.decodeResult, { + latitude: (lat[0] === 'N' ? 1 : -1) * Number(lat.substring(1).trim()), + longitude: (lon[0] === 'E' ? 1 : -1) * Number(lon.substring(1).trim()), + }); + } + + /** A whole number, or undefined for a placeholder. */ + int(i: number): number | undefined { + const v = this.take(i).replace(/ /g, ''); + return /^-?\d+$/.test(v) ? Number(v) : undefined; + } + + altitude(i: number) { + const v = this.int(i); + if (v !== undefined) ResultFormatter.altitude(this.decodeResult, v); + } + + /** Fuel on board, sent in thousands of pounds; written in pounds. */ + fuel(i: number) { + const v = this.take(i).trim(); + if (/^\d+\.\d$/.test(v)) { + ResultFormatter.currentFuel( + this.decodeResult, + Math.round(Number(v) * 1000), + ); + } + } + + eta(i: number) { + const v = this.take(i); + if (/^\d{4}$/.test(v)) { + ResultFormatter.eta( + this.decodeResult, + DateTimeUtils.convertHHMMSSToTod(v), + ); + } + } + + /** Fields not written above, non-empty, to `remaining`. */ + finish() { + const rest = this.fields.filter( + (v, i) => !this.used.has(i) && v.trim() !== '', + ); + if (rest.length > 0) ResultFormatter.unknownArr(this.decodeResult, rest); + return rest.length; + } +} From 9a1eb51e66d08ddd8cd1f041753916e4a0bd1a3c Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:59:11 -0400 Subject: [PATCH 2/3] fix(plugins): keep unusable SWA fields in remaining, per review From the CodeRabbit review of #551: - POS01/HGD03/FHD04: a wind direction or speed sent without the other is written on its own instead of being dropped. - Positions: a coordinate is written only when both are present and in range (latitude 90, longitude 180); otherwise placeholders are consumed and a sent coordinate goes to remaining. - ETA: only 0000-2359 is written; a four-digit value out of range goes to remaining. MXR03 now reports its unused header fields too. - MXR03 whose header fails its layout is shown as text, partial. Tests: one per case above, a known type that breaks its layout, and the FTX01 opening in all nine label 40 tables (the raw forms seen at the start of every FTX01 in a month of airframes traffic). On 225,900 distinct SWA label 37/40 messages from one week, one message decodes differently: a POS01 with a longitude and no latitude now keeps the longitude in remaining. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_37_SWA.test.ts | 106 +++++++++++++++++++++++++++++++ lib/plugins/Label_37_SWA.ts | 22 +++++-- lib/plugins/Label_40_SWA.test.ts | 21 +++++- lib/utils/swa_fields.ts | 35 +++++++--- 4 files changed, 169 insertions(+), 15 deletions(-) diff --git a/lib/plugins/Label_37_SWA.test.ts b/lib/plugins/Label_37_SWA.test.ts index 4dfd3b6..005129b 100644 --- a/lib/plugins/Label_37_SWA.test.ts +++ b/lib/plugins/Label_37_SWA.test.ts @@ -174,6 +174,112 @@ describe('Label_37_SWA', () => { expect(decodeResult.raw.station_iata).toBeUndefined(); }); + test('keeps the wind speed when the wind direction is a placeholder', () => { + // KMCI,KLAS,N 38.523,W100.807,37999,252,460,.797,15.4,2022,259,***,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MK***KH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.wind).toBeUndefined(); + expect(decodeResult.raw.wind_speed).toBe('60 knots'); + expect(decodeResult.raw.wind_direction).toBeUndefined(); + }); + + test('keeps the wind direction when the wind speed is a placeholder', () => { + // KMCI,KLAS,N 38.523,W100.807,37999,252,460,.797,15.4,2022,259,274,***,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NK***K9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.wind).toBeUndefined(); + expect(decodeResult.raw.wind_direction).toBe('274 degrees'); + expect(decodeResult.raw.wind_speed).toBeUndefined(); + }); + + test('keeps a latitude whose longitude is missing', () => { + // KMCI,KLAS,N 38.523,,37999,252,460,.797,15.4,2022,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KK86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.position).toBeUndefined(); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('N 38.523'); + }); + + test('consumes position placeholders without a position', () => { + // KMCI,KLAS,----.---,----.---,37999,252,460,.797,15.4,2022,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-K9999Y999K9999Y999K86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.position).toBeUndefined(); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.remaining.text).toBeUndefined(); + }); + + test('keeps an out-of-range position out of the decoded fields', () => { + // KMCI,KLAS,N 91.001,W181.001,37999,252,460,.797,15.4,2022,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/MJYHHJKXJLJYHHJK86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.position).toBeUndefined(); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('N 91.001,W181.001'); + }); + + test('keeps an out-of-range ETA out of the decoded fields', () => { + // KMCI,KLAS,N 38.523,W100.807,37999,252,460,.797,15.4,2460,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MMMKI:IKN HKY6M6KJ:YNKIN HKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.eta_time).toBeUndefined(); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('2460'); + }); + + test('keeps an out-of-range MXR03 ETA out of the decoded fields', () => { + // KBWI,2460,0| + message.text = '01.:/3P\n)JH(ZO7-3Z3\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.arrival_icao).toBe('KBWI'); + expect(decodeResult.raw.eta_time).toBeUndefined(); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('2460'); + }); + + test('shows MXR03 text only when its header fails the layout', () => { + // KBWI,2226,7| (report codes are 0-3) + message.text = '01.:/3P\n)JH(ZOOO-ZS\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.raw.maintenance_code).toBeUndefined(); + expect(decodeResult.raw.text).toBe('KBWI,2226,7\n'); + }); + + test('shows text only when a known type breaks its layout', () => { + // KMCI,KLAS,N 38.523,W100.807,3799X,252,460,.797,15.4,2022,259,274,060,-53,142080 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MM4KI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLH'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.raw.position).toBeUndefined(); + expect(decodeResult.raw.altitude).toBeUndefined(); + expect(decodeResult.raw.text).toBe( + 'KMCI,KLAS,N 38.523,W100.807,3799X,252,460,.797,15.4,2022,259,274,060,-53,142080', + ); + }); + test('does not decode a body outside the SWA layout', () => { message.text = '01ABCDE'; const decodeResult = plugin.decode(message); diff --git a/lib/plugins/Label_37_SWA.ts b/lib/plugins/Label_37_SWA.ts index 10ef39e..294b856 100644 --- a/lib/plugins/Label_37_SWA.ts +++ b/lib/plugins/Label_37_SWA.ts @@ -106,12 +106,12 @@ export class Label_37_SWA extends DecoderPlugin { prefix && swaMatchesLayout(fields.slice(0, prefix.length), [prefix]) ) { - this.writeMaintenance(decodeResult, d.text, fields); - decodeResult.decoder.decodeLevel = 'full'; + const rest = this.writeMaintenance(decodeResult, d.text, fields); + decodeResult.decoder.decodeLevel = rest === 0 ? 'full' : 'partial'; return decodeResult; } - if (clean && !layouts) { + if (clean && !layouts && !prefix) { // Free text and the report types whose fields are not read: the text in full. const lines = d.text.split('|'); const route = lines[0].match(/^([A-Z]{4}),([A-Z]{4})$/); @@ -176,6 +176,16 @@ export class Label_37_SWA extends DecoderPlugin { 'Wind', `${windDir} degrees at ${windSpeed} knots`, ); + } else if (windDir !== undefined) { + w.item( + 11, + 'wind_direction', + 'WDIR', + 'Wind Direction', + `${windDir} degrees`, + ); + } else if (windSpeed !== undefined) { + w.item(12, 'wind_speed', 'WSPD', 'Wind Speed', `${windSpeed} knots`); } const sat = w.int(13); if (sat !== undefined) @@ -297,12 +307,13 @@ export class Label_37_SWA extends DecoderPlugin { return w.finish(); } - /** MXR03: destination, ETA, code, then the category and text. */ + /** MXR03: destination, ETA, code, then the category and text; returns how many header + * fields went to `remaining`. */ private writeMaintenance( decodeResult: DecodeResult, text: string, fields: string[], - ) { + ): number { const w = new SwaFieldWriter(decodeResult, fields.slice(0, 3)); w.arrival(0); w.eta(1); @@ -319,5 +330,6 @@ export class Label_37_SWA extends DecoderPlugin { if (body.trim() !== '') { ResultFormatter.text(decodeResult, body.split('|').join('\n')); } + return w.finish(); } } diff --git a/lib/plugins/Label_40_SWA.test.ts b/lib/plugins/Label_40_SWA.test.ts index e609fd9..19afa11 100644 --- a/lib/plugins/Label_40_SWA.test.ts +++ b/lib/plugins/Label_40_SWA.test.ts @@ -71,7 +71,7 @@ describe('Label_40_SWA', () => { expect(decodeResult.raw.report_interval).toBe(5); }); - test('decodes free text in each table', () => { + test('decodes free text in table 07', () => { // FTX01 table 07: raw L is Q. message.text = 'FTX01,07L'; const decodeResult = plugin.decode(message); @@ -82,6 +82,25 @@ describe('Label_40_SWA', () => { expect(decodeResult.raw.text).toBe('Q'); }); + test.each([ + // Every FTX01 opens "DISPATCH MSG" and a line break; these are its raw forms in each table. + ['FTX01,01H 73AJEWV.7T\n'], + ['FTX01,026:EUJO0,HBE \n'], + ['FTX01,039BOV-D6L47OH\n'], + ['FTX01,04Q(KCJNO 17KZ\n'], + ['FTX01,052AUO,W0:3IU8\n'], + ['FTX01,0672Z):Y83A.Z4\n'], + ['FTX01,07)5PJWR 3:DP1\n'], + ['FTX01,08(P8Y2H6A1V8,\n'], + ['FTX01,09A2JOKET069J)\n'], + ])('decodes the FTX01 opening %j', (text) => { + message.text = text; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.text).toBe('DISPATCH MSG\n'); + }); + test('does not decode PIC01', () => { message.text = 'PIC01,01ABC'; const decodeResult = plugin.decode(message); diff --git a/lib/utils/swa_fields.ts b/lib/utils/swa_fields.ts index bbe17df..12e673e 100644 --- a/lib/utils/swa_fields.ts +++ b/lib/utils/swa_fields.ts @@ -143,14 +143,27 @@ export class SwaFieldWriter { ResultFormatter.arrivalAirport(this.decodeResult, v); } + /** + * Writes the position when both coordinates are present and in range. Otherwise the + * placeholders are consumed and any coordinate that was sent stays for `remaining`. + */ position(latIndex: number, lonIndex: number) { - const lat = this.take(latIndex); - const lon = this.take(lonIndex); - if (!/^[NS] ?\d/.test(lat) || !/^[EW] ?\d/.test(lon)) return; - ResultFormatter.position(this.decodeResult, { - latitude: (lat[0] === 'N' ? 1 : -1) * Number(lat.substring(1).trim()), - longitude: (lon[0] === 'E' ? 1 : -1) * Number(lon.substring(1).trim()), - }); + const lat = this.fields[latIndex] ?? ''; + const lon = this.fields[lonIndex] ?? ''; + const latitude = /^[NS] ?\d/.test(lat) + ? (lat[0] === 'N' ? 1 : -1) * Number(lat.substring(1).trim()) + : NaN; + const longitude = /^[EW] ?\d/.test(lon) + ? (lon[0] === 'E' ? 1 : -1) * Number(lon.substring(1).trim()) + : NaN; + if (Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180) { + this.used.add(latIndex); + this.used.add(lonIndex); + ResultFormatter.position(this.decodeResult, { latitude, longitude }); + return; + } + if (!/^[NS] ?\d/.test(lat)) this.used.add(latIndex); + if (!/^[EW] ?\d/.test(lon)) this.used.add(lonIndex); } /** A whole number, or undefined for a placeholder. */ @@ -175,13 +188,17 @@ export class SwaFieldWriter { } } + /** HHMM; a placeholder is consumed, and a time out of range stays for `remaining`. */ eta(i: number) { - const v = this.take(i); - if (/^\d{4}$/.test(v)) { + const v = this.fields[i] ?? ''; + if (/^(?:[01]\d|2[0-3])[0-5]\d$/.test(v)) { + this.used.add(i); ResultFormatter.eta( this.decodeResult, DateTimeUtils.convertHHMMSSToTod(v), ); + } else if (!/^\d{4}$/.test(v)) { + this.used.add(i); } } From ecfa8de2ca75e4092ad51115a5ebf85bb8bed24a Mon Sep 17 00:00:00 2001 From: Datalink Pro <84617984+WX4JCW@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:05:42 -0400 Subject: [PATCH 3/3] fix(plugins): SWA crew numbers hidden, POS02, OOOI times, known types Checked against every distinct label 37/40 message in five weeks of airframes traffic plus a year of three aircraft (873,479 messages). - Crew employee numbers: PRM01 and the GEN01 crew schedule uplinks are not decoded, like PIC01. A number typed after CA, FO, FA, CAE, FOE or PILOT: in free text is shown as asterisks. - POS02 reads as a position report; the 16-field POS01 and POS02 send time is written as the message timestamp. - Times: INR04 ETA and report time; OUT03 out time and front door closed; TXI02 taxi start; INN03 in time and front door opened; FLI01, FCM01 report time; TDM01 door event date and time; RTG04/05 out, in and report time. Each was checked against the receive time at real-time stations or against the same flight's door reports (OUT03 door close and INN03 door open within a minute of the TDM01 front door event in 97% and 100%). Fields whose meaning is not established stay in remaining. - Label 40 table code 00 is plain text (GEN01 PDC acknowledgements). - TAR02 ends its header with a comma instead of a line break. - Only type names Southwest sends are decoded; three Horizon messages had decoded to look-alike names. Against the previous commit: 2,202 decoded messages showed a crew position code with an employee number, now none; three other-airline messages were decoded as Southwest, now none. Co-Authored-By: Claude Opus 5.5 --- lib/plugins/Label_37_SWA.test.ts | 153 ++++++++++++++++++++++++++++++- lib/plugins/Label_37_SWA.ts | 54 +++++++++-- lib/plugins/Label_40_SWA.test.ts | 43 +++++++++ lib/plugins/Label_40_SWA.ts | 19 +++- lib/utils/swa_compressed.ts | 82 +++++++++++++++-- lib/utils/swa_fields.ts | 54 +++++++++-- 6 files changed, 375 insertions(+), 30 deletions(-) diff --git a/lib/plugins/Label_37_SWA.test.ts b/lib/plugins/Label_37_SWA.test.ts index 005129b..bacc399 100644 --- a/lib/plugins/Label_37_SWA.test.ts +++ b/lib/plugins/Label_37_SWA.test.ts @@ -110,7 +110,11 @@ describe('Label_37_SWA', () => { door: 'L2 (aft entry)', state: 'open', }); - expect(decodeResult.remaining.text).toBe('1019,033718'); + expect(decodeResult.raw.month).toBe(10); + expect(decodeResult.raw.day).toBe(19); + expect(decodeResult.raw.message_timestamp).toBe(3 * 3600 + 37 * 60 + 18); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.remaining.text).toBeUndefined(); }); test('decodes FCM01 engine and fuel state', () => { @@ -124,7 +128,9 @@ describe('Label_37_SWA', () => { expect(decodeResult.raw.right_engine).toBe('running'); expect(decodeResult.raw.apu).toBe('running'); expect(decodeResult.raw.fuel_on_board).toBe(11500); - expect(decodeResult.remaining.text).toBe('B0,0449'); + expect(decodeResult.raw.message_timestamp).toBe(4 * 3600 + 49 * 60); + // B (most likely the parking brake) is not established, so it stays unread. + expect(decodeResult.remaining.text).toBe('B0'); }); test('decodes MXR03 maintenance report, nothing to report', () => { @@ -280,6 +286,149 @@ describe('Label_37_SWA', () => { ); }); + test('decodes POS02 position report with its send time', () => { + // PHOG,PHNL,N 21.015,W157.673,7578,250,285,.433, 7.7,1624,043,084,013, 9,121200,161925 + message.text = + '036S954\n6GS(K6GOHKOX4BJ5B0KFB0MJCMLKM0M7K405K470KJ LLKXMJMKBC4 K5 LK57 K5BLKXXDKB4B455KBCBD40'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Position Report'); + expect(decodeResult.raw.departure_icao).toBe('PHOG'); + expect(decodeResult.raw.arrival_icao).toBe('PHNL'); + expect(decodeResult.raw.position).toEqual({ + latitude: 21.015, + longitude: -157.673, + }); + expect(decodeResult.raw.altitude).toBe(7578); + expect(decodeResult.raw.gross_weight).toBe('121200 pounds'); + expect(decodeResult.raw.message_timestamp).toBe(16 * 3600 + 19 * 60 + 25); + expect(decodeResult.remaining.text).toBeUndefined(); + }); + + test('decodes the send time of a 16-field POS01', () => { + // KMCI,KLAS,N 38.523,W100.807,37999,252,460,.797,15.4,2022,259,274,060,-53,142080,043032 + message.text = + '09BP-HJ\nA,EVKA(D-KO/8LY:I8KXJHHYLH6K86MMMKI:IKN HKY6M6KJ:YNKIHIIKI:MKI6NKH HK9:8KJNIHLHKHN8H8I'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.message_timestamp).toBe(4 * 3600 + 30 * 60 + 32); + expect(decodeResult.remaining.text).toBeUndefined(); + }); + + test('decodes INR04 in-range report times', () => { + // KDAL,KHOU,1830,1817,12.3,N 30.123,W 95.456 (ETA, then the report time) + message.text = '02N96C:\n,FB.H, TPH3RJCH3R30H3Y)JH9GJC)3YJH5G/4):4D'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.eta_time).toBe(18 * 3600 + 30 * 60); + expect(decodeResult.raw.message_timestamp).toBe(18 * 3600 + 17 * 60); + expect(decodeResult.raw.fuel_on_board).toBe(12300); + expect(decodeResult.raw.position).toEqual({ + latitude: 30.123, + longitude: -95.456, + }); + }); + + test('decodes OUT03 out time and front door close', () => { + // KDAL,KHOU,1205,23.4,23.1,1204 (the second fuel figure is not established) + message.text = '063XYKH\n7- 6O7:3XOJIKFOIHMGOIHMJOJIKG'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.out_time).toBe(12 * 3600 + 5 * 60); + expect(decodeResult.raw.front_door_closed_time).toBe('12:04:00'); + expect(decodeResult.raw.fuel_on_board).toBe(23400); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('23.1'); + }); + + test('decodes TXI02 taxi start time', () => { + // KDAL,KHOU,1210,23.3 + message.text = '07UW4),\n5M0QZ5O7:ZG,G)Z,H(H'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.taxi_start_time).toBe('12:10:00'); + expect(decodeResult.raw.fuel_on_board).toBe(23300); + }); + + test('decodes INN03 in time and front door open', () => { + // KHOU,KDAL,1305,1307,12.1,O,C,C,O (the four door flags are not established) + message.text = '08N77R:\nOT1VGO)H6G :R9G :R4G A0 G1GXGXG1'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.in_time).toBe(13 * 3600 + 5 * 60); + expect(decodeResult.raw.front_door_open_time).toBe('13:07:00'); + expect(decodeResult.raw.fuel_on_board).toBe(12100); + expect(decodeResult.decoder.decodeLevel).toBe('partial'); + expect(decodeResult.remaining.text).toBe('O,C,C,O'); + }); + + test('decodes FLI01 initialization time', () => { + // KDAL,KHOU,1130,23.9,N 32.847,W 96.851 + message.text = '05/2MCZ\nN.I2AN0PSAZZ CAD B,A3V DBGE)ATV,FBG(Z'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.raw.message_timestamp).toBe(11 * 3600 + 30 * 60); + expect(decodeResult.raw.fuel_on_board).toBe(23900); + }); + + test('decodes RTG05 return to gate times', () => { + // KDAL, ,1201,1208,1209,23.4,O,C,C,C (out, in, report time) + message.text = '0382(50\nVTEHKXXXXKB45BKB457KB45DK4LJ KSKQKQKQ'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.raw.out_time).toBe(12 * 3600 + 1 * 60); + expect(decodeResult.raw.in_time).toBe(12 * 3600 + 8 * 60); + expect(decodeResult.raw.message_timestamp).toBe(12 * 3600 + 9 * 60); + expect(decodeResult.raw.fuel_on_board).toBe(23400); + expect(decodeResult.remaining.text).toBe('O,C,C,C'); + }); + + test('decodes TAR02, whose header ends in a comma', () => { + // TAR02,KBOS,KMDW,060,221249,N 42.366,W 70.988 + message.text = '056I5CDAN-PRANO.TACFCADDZDE,A3VEDB FFATV)CB,GG'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.preamble).toBe('TAR02'); + expect(decodeResult.raw.text).toBe( + 'KBOS,KMDW,060,221249,N 42.366,W 70.988', + ); + }); + + test('does not decode a type name Southwest does not send', () => { + // YYY41|KDAL,KHOU: another airline's text can decode to a name of the same shape. + message.text = '019997N\n)LAEZ)DF8'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); + + test('does not decode PRM01 (crew employee numbers)', () => { + // PRM01 with a made-up number: 1234567,KDAL,KHOU| + message.text = '0368N5B\nB4L 0CMKVTEHKVGS/\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.formatted.items.length).toBe(0); + expect(decodeResult.raw.text).toBeUndefined(); + }); + + test('hides a crew employee number in free text', () => { + // KDAL,KHOU|CA 12345 ON BOARD| (made-up number) + message.text = '04QE638\nS6OB2S,C0\nPOH 8L4ZHCTH(COU6\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.text).toBe('CA ***** ON BOARD\n'); + }); + test('does not decode a body outside the SWA layout', () => { message.text = '01ABCDE'; const decodeResult = plugin.decode(message); diff --git a/lib/plugins/Label_37_SWA.ts b/lib/plugins/Label_37_SWA.ts index 294b856..26ce370 100644 --- a/lib/plugins/Label_37_SWA.ts +++ b/lib/plugins/Label_37_SWA.ts @@ -11,6 +11,7 @@ import { SWA_PREFIX_LAYOUTS, SwaFieldWriter, swaFields, + swaHideCrewIds, swaMatchesLayout, } from '../utils/swa_fields'; @@ -23,6 +24,7 @@ const DESCRIPTIONS: Record = { FLI01: 'Flight Initialization', CLB02: 'Climb Report', POS01: 'Position Report', + POS02: 'Position Report', HGD03: 'HowGozit Report', ETA03: 'ETA Report', INR04: 'In Range Report', @@ -78,7 +80,8 @@ export class Label_37_SWA extends DecoderPlugin { decodeResult.message = message; const d = decodeSwaLabel37(message.text); - if (!d || !isSwaType(d.type)) { + // PRM01 carries crew employee numbers; it is not decoded. + if (!d || !isSwaType(d.type, '37') || d.type === 'PRM01') { if (options.debug) { console.log(`Decoder: Unknown 37 message: ${message.text}`); } @@ -92,10 +95,11 @@ export class Label_37_SWA extends DecoderPlugin { DESCRIPTIONS[d.type] ?? 'Southwest Message'; decodeResult.raw.preamble = d.type; - const fields = swaFields(d.text); + const text = swaHideCrewIds(d.text); + const fields = swaFields(text); const layouts = SWA_LAYOUTS[d.type]; const prefix = SWA_PREFIX_LAYOUTS[d.type]; - const clean = !d.text.includes(SWA_UNREAD); + const clean = !text.includes(SWA_UNREAD); if (clean && layouts && swaMatchesLayout(fields, layouts)) { const rest = this.writeFields(decodeResult, d.type, fields); decodeResult.decoder.decodeLevel = rest === 0 ? 'full' : 'partial'; @@ -106,14 +110,14 @@ export class Label_37_SWA extends DecoderPlugin { prefix && swaMatchesLayout(fields.slice(0, prefix.length), [prefix]) ) { - const rest = this.writeMaintenance(decodeResult, d.text, fields); + const rest = this.writeMaintenance(decodeResult, text, fields); decodeResult.decoder.decodeLevel = rest === 0 ? 'full' : 'partial'; return decodeResult; } if (clean && !layouts && !prefix) { // Free text and the report types whose fields are not read: the text in full. - const lines = d.text.split('|'); + const lines = text.split('|'); const route = lines[0].match(/^([A-Z]{4}),([A-Z]{4})$/); if (route && d.type === 'FTD02') { ResultFormatter.departureAirport(decodeResult, route[1]); @@ -126,7 +130,7 @@ export class Label_37_SWA extends DecoderPlugin { } // An unread character or a field that breaks the type's layout: show the text only. - ResultFormatter.text(decodeResult, d.text.split('|').join('\n')); + ResultFormatter.text(decodeResult, text.split('|').join('\n')); decodeResult.decoder.decodeLevel = 'partial'; return decodeResult; } @@ -140,6 +144,7 @@ export class Label_37_SWA extends DecoderPlugin { const w = new SwaFieldWriter(decodeResult, fields); switch (type) { case 'POS01': + case 'POS02': case 'HGD03': case 'FHD04': { // from, to, lat, lon, alt, IAS, TAS, Mach, FOB, ETA, track, wind dir, wind kt, SAT, gross wt @@ -203,6 +208,10 @@ export class Label_37_SWA extends DecoderPlugin { `${gw} pounds`, ); } + // The 16-field POS01 and POS02 end with the send time, HHMMSS. + if (type !== 'FHD04' && fields.length === 16) { + w.time(15, (r, t) => ResultFormatter.timestamp(r, t)); + } if (type === 'FHD04') { w.item(14, 'event', 'EVT', 'Event', fields[14]); w.use(15); @@ -240,35 +249,54 @@ export class Label_37_SWA extends DecoderPlugin { w.position(4, 5); break; case 'INR04': + // ETA (11-19 min after the report time in every one of 34,851), then the report time. w.departure(0); w.arrival(1); + w.eta(2); + w.time(3, (r, t) => ResultFormatter.timestamp(r, t)); w.fuel(4); w.position(5, 6); break; case 'FLI01': w.departure(0); w.arrival(1); + w.time(2, (r, t) => ResultFormatter.timestamp(r, t)); w.fuel(3); w.position(4, 5); break; case 'OUT03': + // Out time, FOB, a second fuel figure (not established), front door (L1) closed. + w.departure(0); + w.arrival(1); + w.time(2, (r, t) => ResultFormatter.out(r, t)); + w.fuel(3); + w.timeItem(5, 'front_door_closed_time', 'L1CLS', 'Front Door Closed'); + break; case 'TXI02': w.departure(0); w.arrival(1); + w.timeItem(2, 'taxi_start_time', 'TAXI', 'Taxi Start Time'); w.fuel(3); break; case 'INN03': w.departure(0); w.arrival(1); + // In time, then front door (L1) opened; the four door flags are not established. + w.time(2, (r, t) => ResultFormatter.in(r, t)); + w.timeItem(3, 'front_door_open_time', 'L1OPN', 'Front Door Opened'); w.fuel(4); break; case 'RTG04': case 'RTG05': + // Out, in, report time; the four door flags are not established. w.departure(0); w.arrival(1); + w.time(2, (r, t) => ResultFormatter.out(r, t)); + w.time(3, (r, t) => ResultFormatter.in(r, t)); + w.time(4, (r, t) => ResultFormatter.timestamp(r, t)); w.fuel(5); break; - case 'TDM01': + case 'TDM01': { w.departure(0); w.arrival(1); w.position(2, 3); @@ -277,7 +305,18 @@ export class Label_37_SWA extends DecoderPlugin { DOORS[w.use(4)], w.use(5) === 'O' ? 'open' : 'closed', ); + // Date (MMDD) and time (HHMMSS) of the door event. + const md = fields[6]; + if (/^(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])$/.test(md)) { + w.use(6); + ResultFormatter.month(decodeResult, Number(md.substring(0, 2))); + ResultFormatter.day(decodeResult, Number(md.substring(2))); + } else if (!/^\d+$/.test(md)) { + w.use(6); + } + w.time(7, (r, t) => ResultFormatter.timestamp(r, t)); break; + } case 'FCM01': { // L, R (engines), A (APU), each 1 running / 0 off; fuel in hundreds of pounds const names: Record = { @@ -298,6 +337,7 @@ export class Label_37_SWA extends DecoderPlugin { } } ResultFormatter.currentFuel(decodeResult, Number(w.use(4)) * 100); + w.time(5, (r, t) => ResultFormatter.timestamp(r, t)); break; } case 'VXR02': diff --git a/lib/plugins/Label_40_SWA.test.ts b/lib/plugins/Label_40_SWA.test.ts index 19afa11..5bddd5d 100644 --- a/lib/plugins/Label_40_SWA.test.ts +++ b/lib/plugins/Label_40_SWA.test.ts @@ -101,6 +101,49 @@ describe('Label_40_SWA', () => { expect(decodeResult.raw.text).toBe('DISPATCH MSG\n'); }); + test('does not decode a crew schedule message (crew employee numbers)', () => { + // MSG FROM CREW SCHEDULE|26 BOS-BWI|CAE 12345 FOE 654321| (made-up numbers) + message.text = + 'GEN01,05IU836SMI30S4)3U0:42YG4\nLT3.MUB.)A\n0,43JLNPR36M43TRPNLJ\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.formatted.items.length).toBe(0); + expect(decodeResult.raw.text).toBeUndefined(); + }); + + test('hides a crew employee number in dispatch free text', () => { + // DISPATCH MSG|FO 654321 IS ON THE SCHEDULE| (made-up number) + message.text = 'FTX01,0672Z):Y83A.Z4\n5,ALMNOPQA2ZA,-AY36AZ8367X/6\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.raw.text).toBe( + 'DISPATCH MSG\nFO ****** IS ON THE SCHEDULE\n', + ); + }); + + test('decodes table code 00 as plain text', () => { + message.text = + 'GEN01,00PRE DEPT CLR ,PDC MSG RECEIVED ,\nPDC NOT AVAILABLE \nCONTACT CLNC DEL \n\n'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(true); + expect(decodeResult.decoder.decodeLevel).toBe('full'); + expect(decodeResult.formatted.description).toBe('Company Message'); + expect(decodeResult.raw.text).toBe( + 'PRE DEPT CLR ,PDC MSG RECEIVED ,\nPDC NOT AVAILABLE \nCONTACT CLNC DEL \n\n', + ); + }); + + test('does not decode a type name Southwest does not send', () => { + message.text = 'ABC12,07L'; + const decodeResult = plugin.decode(message); + + expect(decodeResult.decoded).toBe(false); + expect(decodeResult.decoder.decodeLevel).toBe('none'); + }); + test('does not decode PIC01', () => { message.text = 'PIC01,01ABC'; const decodeResult = plugin.decode(message); diff --git a/lib/plugins/Label_40_SWA.ts b/lib/plugins/Label_40_SWA.ts index 291527d..9975acf 100644 --- a/lib/plugins/Label_40_SWA.ts +++ b/lib/plugins/Label_40_SWA.ts @@ -10,6 +10,8 @@ import { SWA_LAYOUTS, SwaFieldWriter, swaFields, + swaHideCrewIds, + swaIsCrewSchedule, swaMatchesLayout, } from '../utils/swa_fields'; @@ -58,8 +60,14 @@ export class Label_40_SWA extends DecoderPlugin { decodeResult.message = message; const d = decodeSwaLabel40(message.text); - // PIC01 (duty-extension request) carries crew employee numbers; it is not decoded. - if (!d || !isSwaType(d.type) || d.type === 'PIC01') { + // PIC01 (duty-extension request) and the GEN01 crew schedule messages carry crew employee + // numbers; they are not decoded. + if ( + !d || + !isSwaType(d.type, '40') || + d.type === 'PIC01' || + (d.type === 'GEN01' && swaIsCrewSchedule(d.text)) + ) { if (options.debug) { console.log(`Decoder: Unknown 40 message: ${message.text}`); } @@ -91,9 +99,10 @@ export class Label_40_SWA extends DecoderPlugin { return decodeResult; } - const fields = swaFields(d.text); + const text = swaHideCrewIds(d.text); + const fields = swaFields(text); const layouts = SWA_LAYOUTS[d.type]; - const clean = !d.text.includes(SWA_UNREAD); + const clean = !text.includes(SWA_UNREAD); if (clean && layouts && swaMatchesLayout(fields, layouts)) { const w = new SwaFieldWriter(decodeResult, fields); if (d.type === 'ASR01') { @@ -123,7 +132,7 @@ export class Label_40_SWA extends DecoderPlugin { return decodeResult; } - ResultFormatter.text(decodeResult, d.text.split('|').join('\n')); + ResultFormatter.text(decodeResult, text.split('|').join('\n')); decodeResult.decoder.decodeLevel = clean && !layouts ? 'full' : 'partial'; return decodeResult; } diff --git a/lib/utils/swa_compressed.ts b/lib/utils/swa_compressed.ts index 5f27222..8ca5bde 100644 --- a/lib/utils/swa_compressed.ts +++ b/lib/utils/swa_compressed.ts @@ -75,8 +75,11 @@ function plain(table: string, body: string, pass: Record) { export function decodeSwaLabel37(body: string): SwaDecoded | undefined { const b = body.replace(/\r/g, ''); const table = SWA_LABEL_37_TABLES[b.substring(0, 2)]; - const nl = b.indexOf('\n'); - if (!table || nl < 0) return undefined; + if (!table) return undefined; + let nl = b.indexOf('\n'); + // TAR02 ends its header with the field separator instead of a line break. + if (nl !== 7 && b.length > 7 && lookup(table, b[7]) === ',') nl = 7; + if (nl < 0) return undefined; let type = ''; for (const c of b.substring(2, Math.min(nl, 7))) { type += lookup(table, c) ?? SWA_UNREAD; @@ -91,8 +94,15 @@ export function decodeSwaLabel40(body: string): SwaDecoded | undefined { // Not compressed: `POS01,Y,05` turns position reporting on, every 05 minutes. return { type: 'POS01', text: b.trim() }; } - const table = - b[5] === ',' ? SWA_LABEL_40_TABLES[b.substring(6, 8)] : undefined; + const code = b[5] === ',' ? b.substring(6, 8) : ''; + if (code === '00') { + // Table code 00 is plain text (seen on GEN01 PDC acknowledgements). + return { + type: b.substring(0, 5), + text: b.substring(8).replace(/\n/g, '|'), + }; + } + const table = SWA_LABEL_40_TABLES[code]; if (!table) return undefined; return { type: b.substring(0, 5), @@ -100,7 +110,65 @@ export function decodeSwaLabel40(body: string): SwaDecoded | undefined { }; } -/** True for a type name in the SWA form, three letters and two digits (POS01, FTX01). */ -export function isSwaType(type: string): boolean { - return /^[A-Z]{3}\d{2}$/.test(type); +/** + * Type names seen from Southwest aircraft in a year of airframes traffic. Another airline's label + * 37 text can decode to a name of the same shape (TRF04, YYY41), so only these are decoded. + */ +const SWA_TYPES: Record<'37' | '40', Set> = { + '37': new Set([ + 'ADV01', + 'CLB02', + 'DIV02', + 'DVC01', + 'ETA03', + 'FCM01', + 'FCR03', + 'FFD01', + 'FHD04', + 'FLI01', + 'FTD02', + 'GAM02', + 'GSR02', + 'HGD03', + 'HLD01', + 'INN03', + 'INR04', + 'MXR03', + 'OFF02', + 'OFP01', + 'ONN02', + 'OUT03', + 'PFR03', + 'PFR04', + 'PJS01', + 'PNS01', + 'POS01', + 'POS02', + 'PRM01', + 'RRR01', + 'RTG03', + 'RTG04', + 'RTG05', + 'TAR02', + 'TDM01', + 'TOD03', + 'TRB02', + 'TXI02', + 'VXR02', + ]), + '40': new Set([ + 'ASR01', + 'DVP01', + 'FTX01', + 'GAR03', + 'GEN01', + 'PIC01', + 'POS01', + 'WFC01', + ]), +}; + +/** True for a type name Southwest sends on that label (POS01, FTX01). */ +export function isSwaType(type: string, label: '37' | '40'): boolean { + return SWA_TYPES[label].has(type); } diff --git a/lib/utils/swa_fields.ts b/lib/utils/swa_fields.ts index 12e673e..f7bbb61 100644 --- a/lib/utils/swa_fields.ts +++ b/lib/utils/swa_fields.ts @@ -42,6 +42,7 @@ export const SWA_LAYOUTS: Record = { [...POS, INT], [...POS, INT, HMS], ], + POS02: [[...POS, INT, HMS]], HGD03: [[...POS, INT]], FHD04: [ [ @@ -89,6 +90,23 @@ export const SWA_PREFIX_LAYOUTS: Record = { MXR03: [APT, '(?:\\d{4}|)', '[0-3]'], }; +/** A crew position code followed by an employee number: captain, first officer, flight attendant. */ +const CREW_ID = /\b(CAE|FOE|CA|FO|FA|PILOT ?:)( ?)(\d{5,7})\b/g; + +/** Crew schedule uplinks list the crew by employee number; they are not decoded. */ +export function swaIsCrewSchedule(text: string): boolean { + return /CREW SCH|PART ?117|\b(?:CAE|FOE) ?\d{5,7}\b/.test(text); +} + +/** Replaces the digits of an employee number written after a crew position code with '*'. */ +export function swaHideCrewIds(text: string): string { + return text.replace( + CREW_ID, + (_, who: string, gap: string, num: string) => + `${who}${gap}${'*'.repeat(num.length)}`, + ); +} + export function swaFields(text: string): string[] { return text.replace(/\|/g, ',').split(','); } @@ -188,20 +206,38 @@ export class SwaFieldWriter { } } - /** HHMM; a placeholder is consumed, and a time out of range stays for `remaining`. */ - eta(i: number) { + /** + * HHMM or HHMMSS as seconds of the day. A placeholder is consumed; a time out of range is + * left for `remaining`. + */ + tod(i: number): number | undefined { const v = this.fields[i] ?? ''; - if (/^(?:[01]\d|2[0-3])[0-5]\d$/.test(v)) { - this.used.add(i); - ResultFormatter.eta( - this.decodeResult, - DateTimeUtils.convertHHMMSSToTod(v), - ); - } else if (!/^\d{4}$/.test(v)) { + if (/^(?:[01]\d|2[0-3])[0-5]\d(?:[0-5]\d)?$/.test(v)) { this.used.add(i); + return DateTimeUtils.convertHHMMSSToTod(v); + } + if (!/^\d+$/.test(v)) this.used.add(i); + return undefined; + } + + /** Writes a time through a ResultFormatter helper such as `out`, `in` or `timestamp`. */ + time(i: number, write: (decodeResult: DecodeResult, time: number) => void) { + const t = this.tod(i); + if (t !== undefined) write(this.decodeResult, t); + } + + /** A time with no ResultFormatter helper, written as HH:MM:SS. */ + timeItem(i: number, key: string, code: string, label: string) { + const t = this.tod(i); + if (t !== undefined) { + this.item(i, key, code, label, DateTimeUtils.timestampToString(t)); } } + eta(i: number) { + this.time(i, (r, t) => ResultFormatter.eta(r, t)); + } + /** Fields not written above, non-empty, to `remaining`. */ finish() { const rest = this.fields.filter(