@aeroswarm/codec
v0.1.10
Published
Decoding library for aviation surveillance protocols. Converts raw wire-format data (ASTERIX UDP datagrams) into structured records that the rest of the AeroSwarm pipeline normalizes into track events.
Readme
@aeroswarm/codec
Decoding library for aviation surveillance protocols. Converts raw wire-format data (ASTERIX UDP datagrams) into structured records that the rest of the AeroSwarm pipeline normalizes into track events.
Node-only. The decoder reads its XML format definitions from disk at runtime — never import this package from browser code. Frontends consume the already-normalized events via @aeroswarm/client.
Installation
pnpm add @aeroswarm/codecQuick start
import { parseAsterix, resolveAsterixPosition, resolveAsterixAltitudeFt } from "@aeroswarm/codec";
socket.on("message", (buf: Buffer) => {
// A single UDP datagram may contain multiple concatenated ASTERIX blocks —
// parseAsterix handles that and returns one record per block.
const records = parseAsterix(buf);
for (const rec of records) {
// rec.category → 21, 23, 48, …
// rec.edition → which edition actually decoded it, e.g. "0.26"
// rec.fields → decoded values (icao24, lat/lon, callsign, …)
const pos = resolveAsterixPosition(rec.fields, radarSite);
const altFt = resolveAsterixAltitudeFt(rec.fields);
}
});Forcing a specific edition
Auto-detection tries every known edition per category and keeps the richest plausible decode (a wrong-edition UAP can still "successfully" decode with fields landing in the wrong slots, so first-match is not safe). When you know your sensor's edition, pin it — deterministic and one decode per packet instead of several:
// e.g. the kantor radar transmits CAT21 edition 0.26
const records = parseAsterix(buf, { 21: "0.26" });In the services this is wired to the ASTERIX_EDITIONS env var (format "21:0.26,48:1.21").
Supported categories & editions
| Category | Content | Editions (newest first) |
|----------|---------|--------------------------|
| CAT010 | A-SMGCS monosensor surface movement (aircraft + vehicles) | 1.1 |
| CAT011 | A-SMGCS fused surface movement | 1.2 |
| CAT020 | Multilateration (MLAT) target reports | 1.7 |
| CAT021 | ADS-B target reports | 2.4, 1.8, 1.4, 0.26 |
| CAT023 | CNS/ATM ground station & service status | 1.2 |
| CAT034 | Radar service messages (north marker, sector crossing) — sensor id only | 1.27, 1.26 |
| CAT048 | Monoradar (PSR/SSR) target reports | 1.21, 1.14 |
| CAT062 | System/fused tracks from ATC (keyed by system track number; item 380 is compound — only the target address ADR is unpacked into icao24) | 1.18, 1.16, 1.9, 1.7, 0.17 |
| CAT063 | Sensor status — sensor id only | 1.3 |
CAT021 (edition 1.4+) and CAT048 also expose trackNumber (I021/161, I048/161), so reports without a Mode-S address can still be tracked per sensor.
Definitions are standard asterix XML files in src/config/. More category XMLs are already present there (CAT001, 002, 004, …) but not yet registered — see "Adding a category" below.
AFTN / ATS messages
Flight-plan data, not positions: parseAftn turns one raw AFTN message (FPL, CHG, DLA, DEP, ARR, CNL) into plain JSON.
import { parseAftn, splitAftnStream } from "@aeroswarm/codec";
// UDP: one message per datagram
const msg = parseAftn(datagram); // AftnMessage | null
// msg.callsign "BTK7630" · msg.adep "WARR" · msg.ades "WIII" · msg.aircraftType "A320"
// msg.cruiseSpeed "N0450" · msg.cruiseLevel "F350" · msg.route "DCT ABMAL L897 BIKMA DCT"
// msg.icao24 "8a0123" (item 18 CODE/) · msg.eobtAt / atdAt / ataAt (epoch ms, UTC)
// TCP: a message ends at NNNN or ETX
const { messages, rest } = splitAftnStream(buffered + chunk);- Tolerates gateway framing: SOH/STX/ETX, a
ZCZCstart line,NNNN, unknown header lines. Returnsnullwhen no( … )body is present — never throws. - Month and year are resolved from
DOF/or, failing that, the originDDHHMMgroup nearest tooptions.now. icao24comes fromCODE/and is the precise key for correlating a plan with a surveillance track; callsign is the fallback.- Route stays a string (airways, fixes,
DCT, coordinates) — turning fixes into coordinates needs an AIRAC database this package does not carry.
Exposed API
parseAsterix(buf, defaultEdition?)
function parseAsterix(
buf: Buffer,
defaultEdition?: Record<number, string>, // category → edition, skips auto-detect
): AsterixRecord[]Parses one UDP datagram (possibly multiple concatenated blocks) into records. Never throws on malformed input — bad blocks are skipped and an empty array is a valid result. Records failing the plausibility check (lat/lon out of range) are dropped.
resolveAsterixPosition(fields, radarSite?)
Returns { lat, lon } | null. Uses WGS-84 position when present; for radar categories reporting range/azimuth (measuredPosition) or cartesian offsets (positionCartesian), converts relative to radarSite.
resolveAsterixAltitudeFt(fields)
Returns the best available altitude in feet (geometric → flight level preference) or undefined.
Types
interface AsterixRecord { category: number; edition: string; fields: AsterixFields; }
interface AsterixFields {
icao24?: string; // 6-char hex aircraft address
callsign?: string; // e.g. "MAS179"
squawk?: string; // 4-digit octal
lat?: number; lon?: number; // WGS-84 degrees
altitudeFt?: number;
timeOfDay?: number; // seconds since UTC midnight
trackNumber?: number; // per-sensor track number (CAT010/011/020/021/048/062)
velocity?: { groundSpeedKt?: number; headingDeg?: number };
positionCartesian?: { xNm?: number; yNm?: number };
measuredPosition?: { rangeNm?: number; azimuthDeg?: number };
dataSource?: { sac: number; sic: number };
// CAT23 ground-station status
reportType?: number; // 1 = ground station status
gsStatus?: { nogo: boolean; odp: boolean; oxt: boolean; msc: boolean; tsv: boolean; spo: boolean };
operationalRangeNm?: number;
}
interface RadarSite { lat: number; lon: number; }Per-category subpath re-exports
cat10/, cat21/, cat48/, cat62/ re-export the same parser API for callers that want category-scoped imports.
Other protocol entry points (stubs)
decodeAdsbFrame, decodeAis, decodeMlatFrame, decodeAsmgcsFrame exist as placeholders and currently return null. Raw ADS-B/AIS decoding is not implemented — ADS-B arrives via ASTERIX CAT21.
How decoding works (architecture)
XML definition (src/config/asterix_catXXX_Y_Z.xml)
├─ UAP → which FSPEC bit maps to which DataItem
├─ formats → fixed/variable/repetitive/compound/explicit item lengths
└─ <Bits> → named bit-fields: range, signedness, encoding, scale
↓
decoder.ts walks FSPEC + items, slices each present item's bytes
values.ts extractBitFields() turns bytes into named raw values per the XML
fields.ts semantic mapper: raw names → canonical AsterixFields
(XML-first; falls back to manual byte decoding when a
definition lacks usable <Bits> specs)
parser.ts edition selection: forced, or richest-plausible-decode winsValue extraction is config-driven: bit positions, two's-complement handling, scale factors (180/2²⁵ vs 180/2²³ per edition, etc.), 6-bit ICAO callsign chars, octal squawk — all read from the XML. The semantic layer stays as a thin, reviewable mapping because "which item is the position" and altitude-preference rules are policy, not format.
Adding a category or edition
- Drop the standard
asterixXML intosrc/config/(many are already there). - Register it in
FILE_MAP(xml-loader.ts) andEDITIONS(parser.ts). - If the category introduces new semantic fields, extend
AsterixFields(types.ts) and add a small mapping infields.ts. - Add a regression test with a real captured packet in
parser.test.ts.
Testing
pnpm testTests use real packets captured from the live radar feed as fixtures (a CAT21/0.26 MAS179 report and a CAT23 ground-station status), plus multi-block and malformed-input cases. When touching the decoder, always keep these green — they pin exact decoded values.
Low-level API (advanced)
Exposed for tooling/tests; not needed for normal decoding:
| Export | Purpose |
|--------|---------|
| loadDef(cat, edition) | Load + cache one XML definition (AsterixDef: UAP + item formats + bit-fields) |
| decodeBlock(block, def, onField) | Walk one ASTERIX block, invoking onField(id, bytes) per present item |
| parseFspec(buf, start?) | Parse an FX-extended FSPEC into a bit array |
| extractBitFields(data, defs) | Apply BitFieldDef[] to raw item bytes → named values |
| BitFieldDef, ItemDef, AsterixDef, UAPEntry, RawValues | Supporting types |
