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
116 changes: 106 additions & 10 deletions lib/plugins/Label_SQ.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
import { DecoderPlugin } from '../DecoderPlugin';
import { DecodeResult, Message, Options } from '../DecoderPluginInterface';

/**
* Label SQ — Ground Station Squitter
*
* ACARS label SQ denotes a "squitter" — an ID and uplink test message
* transmitted at regular intervals from ACARS ground stations. It is a
* ground-to-air BROADCAST (not a downlink from an aircraft), announcing
* the ground station's presence, position, and VDL-2 capability to any
* aircraft in range.
*
* Extended squitter (version 02) format:
*
* 0 2 X A YVR CYVR 1 4911 N 12310 W V 136975 /ARINC
* | | | | | | | | | | | | | |
* | | | | | | | | | | | | | └ Provider suffix: /ARINC or empty (SITA)
* | | | | | | | | | | | | └──────── VDL-2 frequency (kHz), 136975 → 136.975 MHz
* | | | | | | | | | | | └────────── Separator flag (often 'V' for VDL, but 'B'
* | | | | | | | | | | | and other letters have been observed; the
* | | | | | | | | | | | specific meaning is not documented)
* | | | | | | | | | | └──────────── Longitude hemisphere (E/W)
* | | | | | | | | | └──────────────── Longitude (DDDMM) — deg + 2 digits min
* | | | | | | | | └────────────────── Latitude hemisphere (N/S)
* | | | | | | | └─────────────────────── Latitude (DDMM) — deg + 2 digits min
* | | | | | | └───────────────────────── Ground station number (1-9)
* | | | | | └────────────────────────────── ICAO airport code of the station (4 chars)
* | | | | └────────────────────────────────── IATA airport code of the station (3 chars)
* | | | └──────────────────────────────────── Provider ID: A = ARINC (RC), S = SITA
* | | └────────────────────────────────────── Category: X = ground-to-air uplink broadcast
* | └──────────────────────────────────────── Version: 2 = extended squitter with position + VDL-2
* └────────────────────────────────────────── Version digit 1 (always '0')
*
* Coordinate encoding: lat is 4 digits DDMM, lng is 5 digits DDDMM.
* Example: 5109 N 00012 W → 51°09'N, 000°12'W → London Gatwick (EGKK).
*
* Real-world examples observed:
* 0EYVRCYVR14911N12310WV136975/ARINC ← Vancouver, V separator
* 02XALGWEGKK15109N00012WB136975/ARINC ← Gatwick, B separator
*/
export class Label_SQ extends DecoderPlugin {
name = 'label-sq';

Expand All @@ -11,42 +48,76 @@ export class Label_SQ extends DecoderPlugin {
}

decode(message: Message, options: Options = {}): DecodeResult {
const decodeResult = this.initResult(message, 'Ground Station Squitter');
const decodeResult = this.initResult(
message,
'Ground Station Squitter (ID / Uplink Test — ground-to-air broadcast)'
);

decodeResult.raw.preamble = message.text.substring(0, 4);
decodeResult.raw.version = Number(message.text.substring(1, 2));
decodeResult.raw.network = message.text.substring(3, 4);
// Category char (typically 'X' for uplink broadcast)
decodeResult.raw.category = message.text.substring(2, 3);

if (decodeResult.raw.version === 2) {
// Lat is 4 digits (DDMM), lng is 5 digits (DDDMM). Separator before
// frequency is typically 'V' but 'B' (and possibly others) have been
// observed; capture as a raw flag rather than constraining to 'V'.
const regex =
/0(\d)X(?<org>\w)(?<iata>\w\w\w)(?<icao>\w\w\w\w)(?<station>\d)(?<lat>\d+)(?<latd>[NS])(?<lng>\d+)(?<lngd>[EW])V(?<vfreq>\d+)\/.*/;
/0(\d)(?<cat>[A-Z])(?<org>[A-Z])(?<iata>\w{3})(?<icao>\w{4})(?<station>\d)(?<lat>\d{4})(?<latd>[NS])(?<lng>\d{5})(?<lngd>[EW])(?<sep>[A-Z])(?<vfreq>\d+)(?:\/(?<suffix>\w*))?/;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

The regex uses \w{3} / \w{4} for IATA/ICAO, which also matches digits and underscores. Since these fields are documented here as airport codes and other captured fields are constrained to [A-Z], consider tightening these to [A-Z]{3} and [A-Z]{4} to avoid accepting invalid station identifiers.

Suggested change
/0(\d)(?<cat>[A-Z])(?<org>[A-Z])(?<iata>\w{3})(?<icao>\w{4})(?<station>\d)(?<lat>\d{4})(?<latd>[NS])(?<lng>\d{5})(?<lngd>[EW])(?<sep>[A-Z])(?<vfreq>\d+)(?:\/(?<suffix>\w*))?/;
/0(\d)(?<cat>[A-Z])(?<org>[A-Z])(?<iata>[A-Z]{3})(?<icao>[A-Z]{4})(?<station>\d)(?<lat>\d{4})(?<latd>[NS])(?<lng>\d{5})(?<lngd>[EW])(?<sep>[A-Z])(?<vfreq>\d+)(?:\/(?<suffix>\w*))?/;

Copilot uses AI. Check for mistakes.
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. Tightening \\w to [A-Z] for IATA/ICAO is the right call: \\w accepts [A-Za-z0-9_], none of which (except A-Z) are valid in airport codes. The Copilot suggested edit is good as written — please apply.

const result = message.text.match(regex);

if (result?.groups && result.length >= 8) {
decodeResult.raw.category = result.groups.cat;
Comment on lines 70 to +71
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

result.length >= 8 is tied to the number of capture groups in the regex and doesn’t provide a stable correctness check (it will change if the regex is refactored). Since you already guard on result?.groups, consider removing the length check (or replacing it with explicit group presence checks if needed).

Copilot uses AI. Check for mistakes.
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. result?.groups (truthiness) is the correct guard; result.length >= 8 is brittle and tied to capture-group count. With named groups the safer pattern is to test the specific result.groups.lat etc. you intend to use. Drop the length check.

decodeResult.raw.network = result.groups.org;

// DDMM → decimal degrees (deg + min/60), signed by hemisphere
const latRaw = result.groups.lat;
const lngRaw = result.groups.lng;
const latDeg = Math.floor(Number(latRaw) / 100);
const latMin = Number(latRaw) % 100;
const lngDeg = Math.floor(Number(lngRaw) / 100);
const lngMin = Number(lngRaw) % 100;
const latitude =
(latDeg + latMin / 60) * (result.groups.latd === 'S' ? -1 : 1);
const longitude =
(lngDeg + lngMin / 60) * (result.groups.lngd === 'W' ? -1 : 1);
Comment on lines +63 to +84
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

The SQ parsing behavior changed (DDMM/DDDMM decoding, 5-digit longitude, and non-'V' separators), but the existing unit tests for this plugin will no longer match these rules (e.g., Label_SQ.test.ts currently uses a 4-digit longitude and expects lat/lng to be parsed by raw/100). Please update/add tests to cover the corrected DDMM/DDDMM conversion and at least one non-V separator (e.g., B), so CI doesn’t regress this again.

Copilot uses AI. Check for mistakes.
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 the merge blocker. I ran the test suite locally against the PR head: Label_SQ.test.ts fails (expect(res.raw.groundStation).toBeDefined() is undefined) because the existing JFK fixture (4075 lat, 7398 lng) is no longer valid under the corrected DDMM model AND the longitude regex tightened from \\d+ to \\d{5}. Please rewrite the JFK fixture with realistic DDMM/DDDMM (e.g. 4038 for 40°38′N, 07346 for 73°46′W) and assert with toBeCloseTo. Also add the Gatwick example from your PR body (02XALGWEGKK15109N00012WB136975/ARINC) — that exercises both the B separator and DDDMM with leading zeros.


decodeResult.raw.groundStation = {
number: result.groups.station,
iataCode: result.groups.iata,
icaoCode: result.groups.icao,
coordinates: {
latitude:
(Number(result.groups.lat) / 100) *
(result.groups.latd === 'S' ? -1 : 1),
longitude:
(Number(result.groups.lng) / 100) *
(result.groups.lngd === 'W' ? -1 : 1),
latitude: Math.round(latitude * 1e6) / 1e6,
longitude: Math.round(longitude * 1e6) / 1e6,
},
};
decodeResult.raw.vdlFrequency = Number(result.groups.vfreq) / 1000.0;
decodeResult.raw.separator = result.groups.sep;
if (result.groups.suffix) {
decodeResult.raw.providerSuffix = result.groups.suffix;
}
}
}

var formattedNetwork = 'Unknown';
if (decodeResult.raw.network == 'A') {
if (decodeResult.raw.network === 'A') {
formattedNetwork = 'ARINC';
} else if (decodeResult.raw.network == 'S') {
} else if (decodeResult.raw.network === 'S') {
formattedNetwork = 'SITA';
}
var formattedCategory = String(decodeResult.raw.category || '');
if (formattedCategory === 'X') {
Comment on lines 103 to +110
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

This file is the only one in lib/plugins using var (most plugins use const/let, e.g., lib/plugins/Label_20_POS.ts:18-24). Consider switching formattedNetwork/formattedCategory to let (or const where possible) to match project conventions and avoid function-scoped variables.

Copilot uses AI. Check for mistakes.
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 var was inherited from pre-existing code, but project convention is let/const. Since you're already touching this block, switching both formattedNetwork and formattedCategory to let is a clean opportunistic improvement.

formattedCategory = 'X (ground-to-air uplink broadcast)';
}

decodeResult.formatted.items = [
{
type: 'message_type',
code: 'MSGTYP',
label: 'Message Type',
value: 'Squitter (ID / Uplink Test — ground-to-air broadcast)',
},
{
type: 'network',
code: 'NETT',
Expand All @@ -59,6 +130,12 @@ export class Label_SQ extends DecoderPlugin {
label: 'Version',
value: String(decodeResult.raw.version),
},
{
type: 'category',
code: 'CAT',
label: 'Category',
value: formattedCategory,
},
];

if (decodeResult.raw.groundStation) {
Expand Down Expand Up @@ -115,6 +192,25 @@ export class Label_SQ extends DecoderPlugin {
value: `${decodeResult.raw.vdlFrequency} MHz`,
});
}

if (decodeResult.raw.separator) {
decodeResult.formatted.items.push({
type: 'separator',
code: 'SEP',
label: 'Separator Flag',
value: String(decodeResult.raw.separator),
});
}

if (decodeResult.raw.providerSuffix) {
decodeResult.formatted.items.push({
type: 'provider_suffix',
code: 'PROVSFX',
label: 'Provider Suffix',
value: `/${decodeResult.raw.providerSuffix}`,
});
}

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

return decodeResult;
Expand Down