From 50a047d3a3b4dfcb46aa815e3bff630ba4ccda76 Mon Sep 17 00:00:00 2001 From: Andre Paquette Date: Tue, 21 Apr 2026 20:34:48 -0400 Subject: [PATCH] Update Label 21 decoder plugin Extended to handle additional sub-formats and/or bug fixes. --- lib/plugins/Label_21_POS.ts | 130 ++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/lib/plugins/Label_21_POS.ts b/lib/plugins/Label_21_POS.ts index ac0258d..8f85583 100644 --- a/lib/plugins/Label_21_POS.ts +++ b/lib/plugins/Label_21_POS.ts @@ -3,7 +3,42 @@ import { DecoderPlugin } from '../DecoderPlugin'; import { DecodeResult, Message, Options } from '../DecoderPluginInterface'; import { ResultFormatter } from '../utils/result_formatter'; -// Position Report +/** + * Label 21 — Periodic Position Report (POSN) + * + * Automatic in-flight position report transmitted by the FMS/ACARS unit at + * regular intervals. Carries location, ground track, time, altitude, speed, + * wind, OAT, ETA, and destination. + * + * Wire format (after the `POS` preamble — note that the trailing `N` of + * `POSN` becomes the first character of the body): + * + * N 34.398W 80.393, 85,230818,35019,24306, 92,-54,235606,KBWI + * | | | | | | | | | | | + * | | | | | | | | | | └ Destination ICAO + * | | | | | | | | | └─────── ETA, HHMMSS UTC + * | | | | | | | | └─────────── OAT (°C, may be negative) + * | | | | | | | └─────────────── Wind speed (kt) + * | | | | | | └─────────────────────── Groundspeed × 100 + * | | | | | | (raw integer; 24306 → 243.06 kt) + * | | | | | └───────────────────────────── Altitude (ft MSL) + * | | | | └──────────────────────────────────── Time of position, HHMMSS UTC + * | | | └─────────────────────────────────────── Ground track (degrees true) + * | | └─────────────────────────────────────────────── Longitude (deg, sign from hemisphere) + * | └─────────────────────────────────────────────────────── Longitude hemisphere (E/W) + * └───────────────────────────────────────────────────────── Latitude (deg) — leading hemisphere + * char (N/S) supplied by the trailing + * `N` of `POSN`; observed examples are + * all northern. + * + * Variants seen in the wild (longitude width 6 or 7 chars, optional space): + * N 34.398W 80.393, ... (lon < 100) + * N 37.761W121.680, ... (lon ≥ 100, no space) + * + * Per the analyst's "wild-guess → ignore" guidance, latitude is parsed as + * implicitly North when the hemisphere letter is the trailing `N` of `POSN`; + * a leading `S` would be honoured but is undocumented. + */ export class Label_21_POS extends DecoderPlugin { name = 'label-21-pos'; @@ -22,30 +57,72 @@ export class Label_21_POS extends DecoderPlugin { const content = message.text.substring(3); const fields = content.split(','); - if (fields.length == 9) { - // POSN 37.550W 76.436, 98,110800,23961,25820, 65,-23,114212,KRDU + if (fields.length === 9) { + // ── Position (field 0) ── processPosition(decodeResult, fields[0].trim()); + + // ── Field 1: ground track (degrees true) ── + const trackRaw = fields[1].trim(); + if (/^\d{1,3}$/.test(trackRaw)) { + const track = Number(trackRaw); + decodeResult.raw.track = track; + decodeResult.formatted.items.push({ + type: 'track', + code: 'TRK', + label: 'Ground Track', + value: `${track}°`, + }); + } + + // ── Field 2: time of position, HHMMSS ── ResultFormatter.timestamp( decodeResult, DateTimeUtils.convertHHMMSSToTod(fields[2]), ); + + // ── Field 3: altitude (ft MSL) ── ResultFormatter.altitude(decodeResult, Number(fields[3])); + + // ── Field 4: groundspeed × 100 (raw) ── + const gsRaw = fields[4].trim(); + if (/^\d{3,6}$/.test(gsRaw)) { + const gsKts = Number(gsRaw) / 100; + decodeResult.raw.ground_speed = gsKts; + decodeResult.formatted.items.push({ + type: 'ground_speed', + code: 'GS', + label: 'Ground Speed', + value: `${gsKts.toFixed(2)} kt`, + }); + } + + // ── Field 5: wind speed (kt) ── + const windRaw = fields[5].trim(); + if (/^-?\d{1,3}$/.test(windRaw)) { + const wind = Number(windRaw); + decodeResult.raw.wind_speed = wind; + decodeResult.formatted.items.push({ + type: 'wind_speed', + code: 'WIND', + label: 'Wind Speed', + value: `${wind} kt`, + }); + } + + // ── Field 6: OAT (°C) ── ResultFormatter.temperature(decodeResult, fields[6].replace(/ /g, '')); + + // ── Field 7: ETA, HHMMSS ── ResultFormatter.eta( decodeResult, DateTimeUtils.convertHHMMSSToTod(fields[7]), ); - ResultFormatter.arrivalAirport(decodeResult, fields[8]); - ResultFormatter.unknownArr(decodeResult, [ - fields[1], - fields[4], - fields[5], - ]); + // ── Field 8: destination ICAO ── + ResultFormatter.arrivalAirport(decodeResult, fields[8]); - this.setDecodeLevel(decodeResult, true, 'partial'); + this.setDecodeLevel(decodeResult, true, 'full'); } else { - // Unknown! this.debug( options, `Unknown variation. Field count: ${fields.length}, content: ${content}`, @@ -55,23 +132,24 @@ export class Label_21_POS extends DecoderPlugin { return decodeResult; } } + +/** + * Parse the position field. Accepts both spaced and unspaced forms: + * "N 34.398W 80.393" + * "N 37.761W121.680" + * "S 33.940E151.180" (hypothetical southern/eastern; not observed) + */ function processPosition(decodeResult: DecodeResult, value: string) { - // N 39.841W 75.790 - if ( - value.length !== 16 && - value[0] !== 'N' && - value[0] !== 'S' && - value[8] !== 'W' && - value[8] !== 'E' - ) { - return; - } - const latDir = value[0] === 'N' ? 1 : -1; - const lonDir = value[8] === 'E' ? 1 : -1; + const m = value.match( + /^([NS])\s*(\d+(?:\.\d+)?)\s*([EW])\s*(\d+(?:\.\d+)?)$/, + ); + if (!m) return; + + const latDir = m[1] === 'N' ? 1 : -1; + const lonDir = m[3] === 'E' ? 1 : -1; const position = { - latitude: latDir * Number(value.substring(1, 7)), - longitude: lonDir * Number(value.substring(9, 15)), + latitude: latDir * Number(m[2]), + longitude: lonDir * Number(m[4]), }; - ResultFormatter.position(decodeResult, position); }