Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 203 additions & 8 deletions lib/plugins/Label_B6.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,222 @@
import { DecoderPlugin } from '../DecoderPlugin';
import { DecodeResult, Message, Options } from '../DecoderPluginInterface';
import { ResultFormatter } from '../utils/result_formatter';

// CPDLC
/**
* Label B6 (`/` sub-format) — ADS-C "Provide ADS Report"
*
* ARINC-622-framed FANS-1/A ADS-C surveillance message. Carries periodic
* or on-demand aircraft position surveillance data under an ADS-C contract.
*
* Wire format:
*
* J7 0A QR040X / MELCAYA .ADS. A7-ANT 030BA821
* | | | | | | |
* | | | | | | └ ADS-C payload, ASN.1 / FANS-1/A
* | | | | | | ADS-C encoding (hex)
* | | | | | └────── Aircraft registration (tail)
* | | | | └──────────── Literal `.ADS.` marker
* | | | └───────────────────── Ground-station ARINC address (5–9 chars)
* | | └──────────────────────────── Flight ID (IATA/ICAO callsign)
* | └──────────────────────────────── Message-number block (1–3 chars;
* | often a padding 'A' after the msg#)
* └─────────────────────────────────── Sublabel (typ. `J7` for ADS-C,
* `14` for CPDLC, etc.)
*
* Reference examples:
* 14ASQ0431/SKYSWSQ.ADS.9V-SMI070286A9E26ACA0320401F0E1E88DA00001033927E36F282
* J70AQR040X/MELCAYA.ADS.A7-ANT030BA821
*
* The ADS-C hex payload is preserved verbatim — a full FANS-1/A ADS-C
* decode (basic report: lat/lon/alt/time/quality) requires an ASN.1 PER
* decoder and is deferred to downstream tooling (libacars et al).
*
* Short payloads (≤ 8 hex chars) are too brief to carry a basic position
* report and are typically contract-setup / periodic-contract-request /
* control-plane messages; this is annotated in the formatted output but
* no semantic interpretation is invented.
*/
export class Label_B6_Forwardslash extends DecoderPlugin {
name = 'label-b6-forwardslash';

qualifiers() {
return {
labels: ['B6'],
preambles: ['/'],
preambles: ['/', 'J', '1'],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer reviewer agree — Must Fix. The dispatcher routes by labels AND preambles, so any B6 envelope starting with a sublabel character not in ['/', 'J', '1'] is silently dropped. Recommend dropping preambles entirely and letting labels: ['B6'] plus the .ADS. envelope match drive selection. (If preambles must stay for performance reasons, broaden to all valid 2-char sublabel starts — though that approaches [A-Z0-9] which is effectively no filter.)

};
}

decode(message: Message, options: Options = {}): DecodeResult {
const decodeResult = this.defaultResult();
decodeResult.decoder.name = this.name;
decodeResult.formatted.description = 'CPDLC Message';
decodeResult.message = message;
const decodeResult = this.initResult(
message,
'FANS-1/A ADS-C Provide ADS Report',
);
Comment on lines 49 to +53
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer reviewer agree — this is a Must Fix. 200+ lines of new envelope/regex/cascade logic without a single unit test is a regression-risk magnet. Please add Label_B6.test.ts covering at least: the three documented envelope forms; an HL-tail-with-all-hex case (the motivation for the cascade); an invalid envelope (decoded:false); and a short-payload case (verifies the ADSNOTE row). Without this, future changes to the regex or cascade will silently break decoding.


if (options.debug) {
console.log('CPDLC: ' + message);
const text = (message.text || '').trim();
if (!text) {
this.setDecodeLevel(decodeResult, false);
return decodeResult;
}

// Envelope: <sublabel:2><msgblock:1-2><flight>/<ground>.ADS.<tail+payload>
//
// Flight is a 2- or 3-letter airline code followed by 1-4 digits and an
// optional trailing letter (e.g. QR040X, SQ0431). Because a 2-char
// msg-block can "steal" what looks like an airline letter (e.g. the 'A'
// padding in J70A|QR040X), we try a 2-char msg-block first, then fall
// back to 1 char — the first one that produces a valid-looking flight
// (i.e. that the regex accepts) wins.
const envelopeRe = (msgLen: number) =>
new RegExp(
`^(?<sublabel>[A-Z0-9]{2})(?<msgblock>[A-Z0-9]{${msgLen}})(?<flight>[A-Z]{2,3}\\d{1,4}[A-Z]?)\\/(?<ground>[A-Z0-9]{5,9})\\.ADS\\.(?<rest>.+)$`,
);
// Form 3 — direct `/GROUND.ADS.TAIL<payload>` (no sublabel/flight prefix).
// Observed on some FANS-1/A ADS-C periodic reports, e.g.
// /RPHIAYA.ADS.HL8501070ED832A9374985A7941D0E...
const directRe = /^\/(?<ground>[A-Z0-9]{5,9})\.ADS\.(?<rest>.+)$/;

const m =
text.match(envelopeRe(2)) ||
text.match(envelopeRe(1)) ||
text.match(envelopeRe(0)) ||
text.match(directRe);
Comment on lines +78 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer reviewer agree — Should Fix. Pick one: either extend the matcher to try envelopeRe(3) first, or tighten the docblock to '0–2 chars' to match what's actually implemented. The current discrepancy will mislead future maintainers. If 3-char msgblocks are observed in the wild (the docblock implies they are), extending the matcher is the right call — the cascade ordering (try longest first) is already the correct strategy.

if (!m?.groups) {
this.setDecodeLevel(decodeResult, false);
return decodeResult;
}

const sublabel = (m.groups as any).sublabel as string | undefined;
const msgBlock = (m.groups as any).msgblock as string | undefined;
const flight = (m.groups as any).flight as string | undefined;
const ground = m.groups.ground;
const rest = m.groups.rest;

// Tail/payload split — try a cascade of known ICAO registration
// patterns first (same approach as the AA CPDLC plugin). Digits look
// identical to hex payload bytes, so a naive non-hex-scan truncates
// registrations like HL8501 to "HL". The first candidate that leaves
// a pure-hex tail wins.
const tailCandidates: RegExp[] = [
/^(HL\d{4})/, // Korea
/^(JA\d{1,4}[A-Z]?)/, // Japan
/^([A-Z]-\d{4,5})/, // B-NNNN / B-NNNNN (Taiwan/China)
/^([A-Z]{1,2}-[A-Z0-9]{3,5})/, // A7-ANT, 9V-SMI, LN-RKN, etc.
/^(N\d{1,5}[A-Z]{0,2})/, // US N-numbers
];
let tail = '';
let payload = '';
for (const re of tailCandidates) {
const cm = rest.match(re);
if (!cm) continue;
const cand = cm[1];
const afterCand = rest.substring(cand.length);
if (/^[0-9A-F]*$/i.test(afterCand)) {
tail = cand;
payload = afterCand.toUpperCase();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer reviewer agree — Should Fix. The cascade path uppercases via afterCand.toUpperCase(); the fallback path (payload = rest.substring(...)) preserves casing. Recommend normalizing to uppercase in both paths and updating the docblock to say 'payload normalized to uppercase hex' rather than 'preserved verbatim'. Uppercase is conventional for ARINC hex and matches what most downstream tools (libacars, dumpvdl2) emit.

break;
}
}
if (!tail) {
// Fallback: non-hex scan in the first 8 chars
const window = rest.substring(0, Math.min(8, rest.length));
let lastNonHex = -1;
for (let i = 0; i < window.length; i++) {
Comment on lines +119 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer reviewer agree — Should Fix. The fallback path can produce non-hex payloads and still mark the message decoded: true, level: full. At minimum: after the fallback split, validate /^[0-9A-F]*$/i.test(payload) and call failUnknown (or setDecodeLevel(decodeResult, false)) if it fails. Better: also reject if payload.length is odd (hex bytes are always pairs). This also has implications for the ADS Contract Request Number extraction at line ~150 which assumes pure hex.

if (!/[0-9A-F]/.test(window[i])) lastNonHex = i;
}
if (lastNonHex >= 0) {
tail = rest.substring(0, lastNonHex + 1);
payload = rest.substring(lastNonHex + 1);
} else {
tail = rest.substring(0, 6);
payload = rest.substring(6);
}
}

if (sublabel) decodeResult.raw.sublabel = sublabel;
if (msgBlock) decodeResult.raw.message_number = msgBlock;
if (flight) {
decodeResult.raw.flight_id = flight;
ResultFormatter.flightNumber(decodeResult, flight);
}
decodeResult.raw.ground_address = ground;
decodeResult.raw.tail = tail;
ResultFormatter.tail(decodeResult, tail);
decodeResult.raw.ads_payload_hex = payload;

// The first byte of the ADS payload is the ADS Contract Request Number —
// identifies which ADS contract this periodic report is fulfilling.
// This is the one structured field we surface from the binary payload;
// the rest (lat/lon/alt/time/group tags) requires the full ARINC-745
// FANS-1/A specification to decode reliably and is left to downstream
// tooling such as libacars.
let contractReqNum: number | null = null;
if (/^[0-9A-F]{2,}/i.test(payload)) {
contractReqNum = parseInt(payload.substring(0, 2), 16);
decodeResult.raw.ads_contract_request_number = contractReqNum;
}

const payloadIsShort = payload.length > 0 && payload.length <= 8;
if (payloadIsShort) {
decodeResult.raw.ads_payload_note =
'short payload — likely contract-setup / periodic-contract-request / control-plane message (too brief for a basic position report)';
}

decodeResult.formatted.items.unshift({
type: 'message_type',
code: 'MSGTYP',
label: 'Message Type',
value: 'FANS-1/A ADS-C "Provide ADS Report"',
});
if (sublabel) {
decodeResult.formatted.items.push({
type: 'sublabel',
code: 'SUBLBL',
label: 'Sublabel',
value: sublabel,
});
}

if (msgBlock) {
decodeResult.formatted.items.push({
type: 'message_number',
code: 'MSGNUM',
label: 'Message Number Block',
value: msgBlock,
});
}

decodeResult.formatted.items.push({
type: 'ground_address',
code: 'GND',
label: 'Ground Address',
value: ground,
});
if (contractReqNum !== null) {
decodeResult.formatted.items.push({
type: 'ads_contract_request_number',
code: 'ADSREQ',
label: 'ADS Contract Request #',
value: String(contractReqNum),
});
}
decodeResult.formatted.items.push({
type: 'ads_payload',
code: 'ADSPAYLD',
label: 'ADS-C Payload (hex)',
value: payload || '(none)',
});

if (payloadIsShort) {
decodeResult.formatted.items.push({
type: 'ads_payload_note',
code: 'ADSNOTE',
label: 'Payload Note',
value:
'Short payload — likely a contract-setup / periodic-contract-request / control-plane message (too brief for a basic position report).',
});
}

this.setDecodeLevel(decodeResult, true, 'full');
return decodeResult;
}
}