@aeroswarm/ingest 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # @aeroswarm/ingest
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Switchable surveillance ingest sources for AeroSwarm-style backends. Every
4
+ source emits the same `IngestEvent` union, so a backend swaps its data path —
5
+ **direct site feed** vs **via AeroSwarm** — with config alone.
6
+
7
+ ```ts
8
+ import { createFeedSource } from "@aeroswarm/ingest"; // direct
9
+ import { KafkaSource } from "@aeroswarm/ingest/kafka"; // via AeroSwarm
10
+ import { WsSource } from "@aeroswarm/ingest/ws"; // via AeroSwarm (track-only)
11
+
12
+ const source =
13
+ MODE === "direct"
14
+ ? createFeedSource({ protocol: "asterix", port: 13217, multicastGroup: "224.1.1.109", airportId: "WARR" })
15
+ : new KafkaSource({ brokers: ["redpanda:9092"], groupId: "backend-djuanda" });
16
+
17
+ await source.start((ev) => {
18
+ if (ev.kind === "track") pipeline.pushTrack(ev.event);
19
+ if (ev.kind === "flightplan") pipeline.pushPlan(ev.event);
20
+ if (ev.kind === "health") pipeline.pushHealth(ev.event);
21
+ });
22
+ ```
23
+
24
+ ## Event union
25
+
26
+ | `kind` | Payload | Emitted by |
27
+ |---|---|---|
28
+ | `track` | `FusedTrackEvent` (direct/Kafka) or `CurrentTrack` (WS) | all |
29
+ | `trackRemoved` | `{ trackId, reason }` | WS |
30
+ | `flightplan` | `FlightPlanEvent` (FPL/CHG/DLA/DEP/ARR/CNL) | AFTN feed, Kafka |
31
+ | `health` | `SourceHealthEvent` (CAT23 GS status, packet rates) | ASTERIX feed, Kafka |
32
+ | `raw` | `{ topic, key, fields }` | ASTERIX feed, Kafka (non-pipeline topics) |
33
+ | `status` | `{ status, reason }` | WS (`source.status`, `stream.degraded/error`) |
34
+
35
+ Narrow `track` payloads with `"position" in ev.event` (`FusedTrackEvent`) vs
36
+ `"lat" in ev.event` (`CurrentTrack`).
37
+
38
+ ## Direct feeds — `createFeedSource(config)`
39
+
40
+ | Field | `asterix` | `aftn` |
41
+ |---|---|---|
42
+ | `port` | UDP listen / TCP remote port | same |
43
+ | `transport` | `"udp"` (default) \| `"tcp"` | same |
44
+ | `host` | TCP remote host | TCP remote host |
45
+ | `multicastGroup` / `multicastIface` | join a multicast feed | same |
46
+ | `airportId` / `sourceId` / `feedId` | stamped on emitted events | same |
47
+ | `allowedCats` / `forcedEditions` | decode filters | — |
48
+ | `radarSite` | polar→WGS84 for CAT020/048 | — |
49
+
50
+ `stats` mirrors the simulator-service feed counters (`packetsReceived`,
51
+ `packetsForwarded`, `packetsUndecoded`, `gsStatus`, `lastMessage`, …).
52
+
53
+ Differences vs going through AeroSwarm (honest): direct feeds emit **per-feed
54
+ raw** FusedTrackEvents — no multi-sensor fusion, and `flightPlan` never rides
55
+ on the track (plans arrive as separate `flightplan` events; correlate with
56
+ `applyFlightPlanEvent` / `pickMatchingPlan` from `@aeroswarm/core`).
57
+
58
+ ## AeroSwarm relays
59
+
60
+ **`KafkaSource`** (peer dep `kafkajs`) — consumes `aero.fused.track.v1`,
61
+ `aero.flightplan.v1`, `aero.source.health.v1` (overridable via `topics`). The
62
+ faithful path: standalone flight plans arrive even with no track correlation.
63
+
64
+ **`WsSource`** — subscribes `/ws/tracks/live` (snapshot + diff of
65
+ `CurrentTrack`, `flightPlan` attached when correlated). Track-only: no
66
+ standalone plans, no health. On Node < 22 pass `WebSocketImpl` (e.g. `ws`).
67
+ Zero dependencies otherwise — uses the global `WebSocket`.
68
+
69
+ ## Status
70
+
71
+ - `simulator-service` still carries its own copies of these listeners; a
72
+ later task migrates it onto this package (the ports here are byte-for-byte
73
+ ports of `AsterixUdpService` / `AftnService`).
@@ -0,0 +1,50 @@
1
+ import type { IngestEventHandler, IngestSource, IngestSourceStats } from "../types.js";
2
+ export interface AftnFeedStats extends IngestSourceStats {
3
+ lastMessage: {
4
+ type: string;
5
+ callsign?: string;
6
+ adep?: string;
7
+ ades?: string;
8
+ at: number;
9
+ } | null;
10
+ }
11
+ export interface AftnFeedConfig {
12
+ /** UDP listen port (udp) or remote port (tcp) */
13
+ port: number;
14
+ transport?: "udp" | "tcp";
15
+ /** TCP only: remote AFTN gateway host */
16
+ host?: string;
17
+ airportId?: string;
18
+ sourceId?: string;
19
+ feedId?: string;
20
+ multicastGroup?: string;
21
+ multicastIface?: string;
22
+ }
23
+ /** Direct AFTN/ATS feed source: UDP (one message per datagram) or a TCP
24
+ * client (messages end at NNNN / ETX). Emits kind "flightplan" for every
25
+ * parsed message — folding into flight plans and correlating with tracks is
26
+ * the consumer's job (applyFlightPlanEvent / pickMatchingPlan in core). */
27
+ export declare class AftnFeedSource implements IngestSource {
28
+ private readonly cfg;
29
+ readonly stats: AftnFeedStats;
30
+ private socket;
31
+ private tcpSocket;
32
+ private tcpBuffer;
33
+ private tcpReconnectTimer;
34
+ private shouldRun;
35
+ private emit;
36
+ constructor(cfg?: AftnFeedConfig);
37
+ private sourceId;
38
+ private resetState;
39
+ /** Parse one raw message and emit it. */
40
+ processMessage(raw: string | Buffer): void;
41
+ /** A UDP datagram normally holds one message; tolerate several back to back. */
42
+ private processDatagram;
43
+ start(onEvent: IngestEventHandler): void;
44
+ private startUdp;
45
+ private startTcp;
46
+ private connectTcp;
47
+ private closeSockets;
48
+ stop(): Promise<void>;
49
+ }
50
+ //# sourceMappingURL=aftn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aftn.d.ts","sourceRoot":"","sources":["../../src/feed/aftn.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAOvF,MAAM,WAAW,aAAc,SAAQ,iBAAiB;IACtD,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACnG;AAED,MAAM,WAAW,cAAc;IAC7B,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;4EAG4E;AAC5E,qBAAa,cAAe,YAAW,YAAY;IAcrC,OAAO,CAAC,QAAQ,CAAC,GAAG;IAbhC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAI3B;IAEF,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,IAAI,CAAgC;gBAEf,GAAG,GAAE,cAA4B;IAE9D,OAAO,CAAC,QAAQ;IAEhB,OAAO,CAAC,UAAU;IASlB,yCAAyC;IACzC,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAmC1C,gFAAgF;IAChF,OAAO,CAAC,eAAe;IASvB,KAAK,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI;IAUxC,OAAO,CAAC,QAAQ;IAyBhB,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,UAAU;IAuClB,OAAO,CAAC,YAAY;IAMd,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAO5B"}
@@ -0,0 +1,76 @@
1
+ import type { RadarSite } from "@aeroswarm/codec";
2
+ import type { IngestEventHandler, IngestSource, IngestSourceStats } from "../types.js";
3
+ export interface AsterixFeedStats extends IngestSourceStats {
4
+ lastDecoded: {
5
+ category: number;
6
+ edition: string;
7
+ icao24?: string;
8
+ lat?: number;
9
+ lon?: number;
10
+ at: number;
11
+ } | null;
12
+ /** Latest CAT23 ground-station status report */
13
+ gsStatus: {
14
+ sac?: number;
15
+ sic?: number;
16
+ status: "healthy" | "degraded" | "offline";
17
+ operationalRangeNm?: number;
18
+ at: number;
19
+ } | null;
20
+ }
21
+ /** Per-feed configuration — every field explicit (no env fallbacks inside the
22
+ * package; the host process supplies them). */
23
+ export interface AsterixFeedConfig {
24
+ /** UDP listen port (udp) or remote port (tcp) */
25
+ port: number;
26
+ transport?: "udp" | "tcp";
27
+ /** TCP only: remote host to connect out to */
28
+ host?: string;
29
+ airportId?: string;
30
+ sourceId?: string;
31
+ feedId?: string;
32
+ allowedCats?: Set<number>;
33
+ forcedEditions?: Record<number, string>;
34
+ multicastGroup?: string;
35
+ multicastIface?: string;
36
+ /** Radar site position for CAT020/048 polar→WGS84 resolution */
37
+ radarSite?: RadarSite;
38
+ }
39
+ /** Direct ASTERIX feed source: UDP datagrams (unicast or multicast) or a TCP
40
+ * client stream ([CAT][LEN]… blocks), decoded to FusedTrackEvents. Emits
41
+ * kind "track" (fused), "health" (CAT23 ground-station status + dropped
42
+ * lifecycle tracks), and "raw" (per-category field dump). */
43
+ export declare class AsterixFeedSource implements IngestSource {
44
+ private readonly cfg;
45
+ readonly stats: AsterixFeedStats;
46
+ private lastHealth;
47
+ private socket;
48
+ private tcpSocket;
49
+ private tcpBuffer;
50
+ private tcpReconnectTimer;
51
+ private shouldRun;
52
+ private trackMap;
53
+ private sweepTimer;
54
+ private emit;
55
+ constructor(cfg?: AsterixFeedConfig);
56
+ private sourceId;
57
+ private meta;
58
+ private makeFused;
59
+ /** CAT23 report → source health event. NOGO means the ground station itself
60
+ * says its data must not be used operationally. */
61
+ private handleGsStatus;
62
+ /** Decode one ASTERIX datagram/block and emit the resulting tracks. */
63
+ processDatagram(buf: Buffer): void;
64
+ private resetState;
65
+ start(onEvent: IngestEventHandler): void;
66
+ private startUdp;
67
+ private startTcp;
68
+ private connectTcp;
69
+ private closeSockets;
70
+ private emitDropped;
71
+ /** Evict track entries idle longer than TRACK_MAP_TTL_MS, emitting a
72
+ * "dropped" lifecycle event for each. */
73
+ private sweepTrackMap;
74
+ stop(): Promise<void>;
75
+ }
76
+ //# sourceMappingURL=asterix.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asterix.d.ts","sourceRoot":"","sources":["../../src/feed/asterix.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAGjE,OAAO,KAAK,EAAe,kBAAkB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAgBpG,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD,WAAW,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACnH,gDAAgD;IAChD,QAAQ,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;QAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACtI;AAED;gDACgD;AAChD,MAAM,WAAW,iBAAiB;IAChC,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,8CAA8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAID;;;8DAG8D;AAC9D,qBAAa,iBAAkB,YAAW,YAAY;IAwBxC,OAAO,CAAC,QAAQ,CAAC,GAAG;IAvBhC,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAM9B;IAEF,OAAO,CAAC,UAAU,CAAyB;IAC3C,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,SAAS,CAA2B;IAC5C,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAKX;IACL,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,IAAI,CAAgC;gBAEf,GAAG,GAAE,iBAA+B;IAEjE,OAAO,CAAC,QAAQ;IAEhB,OAAO,CAAC,IAAI;IASZ,OAAO,CAAC,SAAS;IAoBjB;wDACoD;IACpD,OAAO,CAAC,cAAc;IAqCtB,uEAAuE;IACvE,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IA6IlC,OAAO,CAAC,UAAU;IAkBlB,KAAK,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI;IAUxC,OAAO,CAAC,QAAQ;IAyBhB,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,UAAU;YAgDJ,YAAY;IAM1B,OAAO,CAAC,WAAW;IASnB;8CAC0C;IAC1C,OAAO,CAAC,aAAa;IASf,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAU5B"}
@@ -0,0 +1,17 @@
1
+ import { AsterixFeedSource } from "./feed/asterix.js";
2
+ import type { AsterixFeedConfig, AsterixFeedStats } from "./feed/asterix.js";
3
+ import { AftnFeedSource } from "./feed/aftn.js";
4
+ import type { AftnFeedConfig, AftnFeedStats } from "./feed/aftn.js";
5
+ export type { IngestEvent, IngestEventHandler, IngestSource, IngestSourceStats } from "./types.js";
6
+ export { AsterixFeedSource, AftnFeedSource };
7
+ export type { AsterixFeedConfig, AsterixFeedStats, AftnFeedConfig, AftnFeedStats };
8
+ export type FeedSourceConfig = ({
9
+ protocol: "asterix";
10
+ } & AsterixFeedConfig) | ({
11
+ protocol: "aftn";
12
+ } & AftnFeedConfig);
13
+ /** Direct feed factory — a UDP/TCP listener bound to this process, decoded
14
+ * locally (no AeroSwarm in the path). For the "via AeroSwarm" path use
15
+ * KafkaSource / WsSource from the subpath exports. */
16
+ export declare function createFeedSource(cfg: FeedSourceConfig): AsterixFeedSource | AftnFeedSource;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACnG,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,CAAC;AAC7C,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC;AAEnF,MAAM,MAAM,gBAAgB,GACxB,CAAC;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,GAAG,iBAAiB,CAAC,GAC7C,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAAC,CAAC;AAE5C;;uDAEuD;AACvD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,gBAAgB,GAAG,iBAAiB,GAAG,cAAc,CAK1F"}
package/dist/index.js ADDED
@@ -0,0 +1,605 @@
1
+ // src/feed/asterix.ts
2
+ import { createSocket } from "dgram";
3
+ import { connect as tcpConnect } from "net";
4
+ import { parseAsterix, resolveAsterixPosition, resolveAsterixAltitudeFt } from "@aeroswarm/codec";
5
+ import { createLogger, createNanoid } from "@aeroswarm/core";
6
+ var log = createLogger("ingest:asterix");
7
+ var RAW_CAT21_TOPIC = "aero.raw.asterix.cat021.v1";
8
+ var RAW_CAT10_TOPIC = "aero.raw.asterix.cat010.v1";
9
+ var RAW_ADSB_TOPIC = "aero.raw.adsb.v1";
10
+ var TCP_MAX_BUFFER = 64 * 1024;
11
+ var TCP_RECONNECT_MS = 5e3;
12
+ var TRACK_MAP_TTL_MS = 10 * 6e4;
13
+ var TRACK_MAP_SWEEP_MS = 6e4;
14
+ var DEFAULT_ALLOWED_CATS = /* @__PURE__ */ new Set([10, 11, 20, 21, 48, 62]);
15
+ var AsterixFeedSource = class {
16
+ constructor(cfg = { port: 0 }) {
17
+ this.cfg = cfg;
18
+ }
19
+ cfg;
20
+ stats = {
21
+ running: false,
22
+ transport: "udp",
23
+ port: 0,
24
+ remote: null,
25
+ packetsReceived: 0,
26
+ packetsForwarded: 0,
27
+ packetsRejected: 0,
28
+ packetsSkipped: 0,
29
+ packetsUndecoded: 0,
30
+ lastDecoded: null,
31
+ gsStatus: null,
32
+ lastError: null
33
+ };
34
+ lastHealth = { at: 0, packets: 0 };
35
+ socket = null;
36
+ tcpSocket = null;
37
+ tcpBuffer = Buffer.alloc(0);
38
+ tcpReconnectTimer = null;
39
+ shouldRun = false;
40
+ trackMap = /* @__PURE__ */ new Map();
41
+ sweepTimer = null;
42
+ emit = () => {
43
+ };
44
+ sourceId() {
45
+ return this.cfg.sourceId ?? "ingest";
46
+ }
47
+ meta() {
48
+ return {
49
+ sourceType: this.stats.transport === "tcp" ? "ASTERIX_TCP" : "ASTERIX_UDP",
50
+ port: this.stats.port,
51
+ ...this.cfg.feedId && { feedId: this.cfg.feedId },
52
+ ingest: "@aeroswarm/ingest"
53
+ };
54
+ }
55
+ makeFused(trackId, icao24, callsign, lat, lon, altFt, speedKt, headingDeg, verticalRateFpm, protocol, meta) {
56
+ const now = Date.now();
57
+ return {
58
+ trackId,
59
+ sources: [this.sourceId()],
60
+ identity: { icao24, callsign },
61
+ protocol,
62
+ position: { lat, lon, alt: altFt, altSource: "baro" },
63
+ velocity: { groundSpeed: speedKt, heading: headingDeg, verticalRate: verticalRateFpm },
64
+ confidence: { overall: 0.85, position: 0.9, identity: 0.95, velocity: 0.8, sourceAgreement: 1, spoofRisk: 0.05 },
65
+ fusedAt: now,
66
+ updatedAt: now,
67
+ lifecycle: "updated",
68
+ meta
69
+ };
70
+ }
71
+ /** CAT23 report → source health event. NOGO means the ground station itself
72
+ * says its data must not be used operationally. */
73
+ handleGsStatus(f) {
74
+ if (f.reportType !== void 0 && f.reportType !== 1) return;
75
+ const gs = f.gsStatus;
76
+ const status = gs?.nogo ? "offline" : gs?.odp || gs?.oxt || gs?.tsv ? "degraded" : "healthy";
77
+ const now = Date.now();
78
+ const dt = (now - this.lastHealth.at) / 1e3;
79
+ const packetsPerSecond = this.lastHealth.at > 0 && dt > 0 ? Math.round((this.stats.packetsReceived - this.lastHealth.packets) / dt) : 0;
80
+ this.lastHealth = { at: now, packets: this.stats.packetsReceived };
81
+ const prev = this.stats.gsStatus?.status;
82
+ this.stats.gsStatus = {
83
+ sac: f.dataSource?.sac,
84
+ sic: f.dataSource?.sic,
85
+ status,
86
+ operationalRangeNm: f.operationalRangeNm,
87
+ at: now
88
+ };
89
+ if (prev !== status) {
90
+ log.info({ sac: f.dataSource?.sac, sic: f.dataSource?.sic, status, prev: prev ?? "none" }, "ground station status changed");
91
+ }
92
+ this.emit({
93
+ kind: "health",
94
+ event: {
95
+ sourceId: this.sourceId(),
96
+ status,
97
+ timestamp: now,
98
+ latencyMs: 0,
99
+ packetLossRate: 0,
100
+ packetsPerSecond,
101
+ bufferFillRatio: 0
102
+ }
103
+ });
104
+ }
105
+ /** Decode one ASTERIX datagram/block and emit the resulting tracks. */
106
+ processDatagram(buf) {
107
+ this.stats.packetsReceived++;
108
+ if (buf.length < 3) {
109
+ this.stats.packetsSkipped++;
110
+ return;
111
+ }
112
+ const allowedCats = this.cfg.allowedCats ?? DEFAULT_ALLOWED_CATS;
113
+ try {
114
+ const records = parseAsterix(
115
+ buf,
116
+ this.cfg.forcedEditions && Object.keys(this.cfg.forcedEditions).length > 0 ? this.cfg.forcedEditions : void 0
117
+ );
118
+ if (records.length === 0) {
119
+ this.stats.packetsUndecoded++;
120
+ if (this.stats.packetsUndecoded === 1 || this.stats.packetsUndecoded % 1e3 === 0) {
121
+ log.warn(
122
+ { count: this.stats.packetsUndecoded, cat: buf[0], len: buf.length, head: buf.subarray(0, 16).toString("hex") },
123
+ "packet parsed to zero records \u2014 no ASTERIX edition decoded it"
124
+ );
125
+ }
126
+ return;
127
+ }
128
+ for (const rec of records) {
129
+ if (rec.category === 23) {
130
+ this.handleGsStatus(rec.fields);
131
+ continue;
132
+ }
133
+ if (rec.category === 34 || rec.category === 63 || rec.category === 19 || rec.category === 247) continue;
134
+ if (!allowedCats.has(rec.category)) {
135
+ this.stats.packetsSkipped++;
136
+ continue;
137
+ }
138
+ const f = rec.fields;
139
+ const isSurface = rec.category === 10 || rec.category === 11;
140
+ let icao24 = f.icao24;
141
+ if (!icao24 && f.trackNumber !== void 0) {
142
+ const prefix = isSurface ? "v" : rec.category === 62 ? "t" : "r";
143
+ icao24 = `${prefix}${f.dataSource?.sac ?? 0}-${f.dataSource?.sic ?? 0}-${f.trackNumber}`;
144
+ }
145
+ if (!icao24) {
146
+ this.stats.packetsSkipped++;
147
+ continue;
148
+ }
149
+ const pos = resolveAsterixPosition(f, this.cfg.radarSite ?? null);
150
+ if (!pos) {
151
+ this.stats.packetsSkipped++;
152
+ continue;
153
+ }
154
+ this.stats.lastDecoded = {
155
+ category: rec.category,
156
+ edition: rec.edition,
157
+ icao24,
158
+ lat: pos.lat,
159
+ lon: pos.lon,
160
+ at: Date.now()
161
+ };
162
+ const altFt = resolveAsterixAltitudeFt(f) ?? 0;
163
+ const vel = f["velocity"];
164
+ const callsign = f["callsign"];
165
+ const squawk = f["squawk"];
166
+ const now = Date.now();
167
+ let prevEntry = this.trackMap.get(icao24);
168
+ if ((icao24.startsWith("v") || icao24.startsWith("t") || icao24.startsWith("r")) && prevEntry?.lastPos) {
169
+ const dtS = (now - prevEntry.lastPos.at) / 1e3;
170
+ if (dtS > 0.2) {
171
+ const kx = 111320 * Math.cos(pos.lat * Math.PI / 180);
172
+ const dxM = (pos.lon - prevEntry.lastPos.lon) * kx;
173
+ const dyM = (pos.lat - prevEntry.lastPos.lat) * 111320;
174
+ const derivedKt = Math.hypot(dxM, dyM) / dtS * 1.94384;
175
+ const maxPlausibleKt = icao24.startsWith("v") ? 100 : 700;
176
+ if (derivedKt > maxPlausibleKt) prevEntry = void 0;
177
+ }
178
+ }
179
+ const trackId = prevEntry?.trackId ?? `ast_${createNanoid(12)}`;
180
+ let gs = vel?.groundSpeedKt;
181
+ let hdg = vel?.headingDeg;
182
+ if (gs !== void 0 && (gs < 0 || gs > 1200)) gs = void 0;
183
+ if ((gs === void 0 || hdg === void 0) && prevEntry?.lastPos) {
184
+ const dtS = (now - prevEntry.lastPos.at) / 1e3;
185
+ if (dtS > 0.2 && dtS < 30) {
186
+ const kx = 111320 * Math.cos(pos.lat * Math.PI / 180);
187
+ const dxM = (pos.lon - prevEntry.lastPos.lon) * kx;
188
+ const dyM = (pos.lat - prevEntry.lastPos.lat) * 111320;
189
+ const distM = Math.hypot(dxM, dyM);
190
+ const derivedKt = distM / dtS * 1.94384;
191
+ if (distM > 2 && derivedKt <= 700) {
192
+ if (hdg === void 0) hdg = (Math.atan2(dxM, dyM) * 180 / Math.PI + 360) % 360;
193
+ if (gs === void 0) gs = derivedKt;
194
+ }
195
+ }
196
+ }
197
+ if (gs === void 0) gs = prevEntry?.lastVel?.gs ?? 0;
198
+ if (hdg === void 0) hdg = prevEntry?.lastVel?.hdg ?? 0;
199
+ this.trackMap.set(icao24, {
200
+ trackId,
201
+ lastSeenAt: now,
202
+ lastVel: { gs, hdg },
203
+ lastPos: { lat: pos.lat, lon: pos.lon, at: now }
204
+ });
205
+ const rawTopic = rec.category === 21 ? RAW_CAT21_TOPIC : isSurface ? RAW_CAT10_TOPIC : RAW_ADSB_TOPIC;
206
+ this.emit({
207
+ kind: "raw",
208
+ topic: rawTopic,
209
+ key: icao24,
210
+ fields: { ...f, category: rec.category, sourceId: this.sourceId(), receivedAt: now }
211
+ });
212
+ const protocol = rec.category === 10 ? "asterix-cat010" : rec.category === 11 ? "asterix-cat011" : rec.category === 20 ? "asterix-cat020" : rec.category === 21 ? "asterix-cat021" : rec.category === 62 ? "asterix-cat062" : "asterix-cat048";
213
+ const fused = this.makeFused(
214
+ trackId,
215
+ icao24,
216
+ callsign,
217
+ pos.lat,
218
+ pos.lon,
219
+ altFt,
220
+ gs,
221
+ hdg,
222
+ 0,
223
+ protocol,
224
+ { ...this.meta(), receivedAt: now }
225
+ );
226
+ if (squawk) fused.identity = { ...fused.identity, squawk };
227
+ if (this.cfg.airportId) fused.airportId = this.cfg.airportId;
228
+ if (isSurface) {
229
+ const targetType = f.targetType ?? (f.icao24 ? "aircraft" : "unknown");
230
+ fused.identity = {
231
+ ...fused.identity,
232
+ targetType,
233
+ ...f.vehicleFleet !== void 0 && { vehicleFleet: f.vehicleFleet }
234
+ };
235
+ }
236
+ this.emit({ kind: "track", event: fused });
237
+ this.stats.packetsForwarded++;
238
+ }
239
+ } catch (err) {
240
+ this.stats.packetsRejected++;
241
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
242
+ }
243
+ }
244
+ resetState(patch) {
245
+ Object.assign(this.stats, {
246
+ running: true,
247
+ transport: "udp",
248
+ port: 0,
249
+ remote: null,
250
+ packetsReceived: 0,
251
+ packetsForwarded: 0,
252
+ packetsRejected: 0,
253
+ packetsSkipped: 0,
254
+ packetsUndecoded: 0,
255
+ lastDecoded: null,
256
+ gsStatus: null,
257
+ lastError: null,
258
+ ...patch
259
+ });
260
+ this.lastHealth = { at: 0, packets: 0 };
261
+ this.trackMap.clear();
262
+ if (!this.sweepTimer) {
263
+ this.sweepTimer = setInterval(() => this.sweepTrackMap(), TRACK_MAP_SWEEP_MS);
264
+ }
265
+ }
266
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
267
+ start(onEvent) {
268
+ this.emit = onEvent;
269
+ if (this.cfg.transport === "tcp") {
270
+ if (!this.cfg.host) throw new Error("AsterixFeedSource: host is required for TCP transport");
271
+ this.startTcp(this.cfg.host, this.cfg.port);
272
+ } else {
273
+ this.startUdp(this.cfg.port);
274
+ }
275
+ }
276
+ startUdp(port) {
277
+ void this.closeSockets();
278
+ this.shouldRun = true;
279
+ this.socket = createSocket({ type: "udp4", reuseAddr: true });
280
+ this.socket.on("message", (buf) => this.processDatagram(buf));
281
+ this.socket.on("error", (err) => {
282
+ this.stats.lastError = err.message;
283
+ log.warn({ err: err.message }, "asterix UDP socket error");
284
+ });
285
+ this.socket.bind(port, "0.0.0.0", () => {
286
+ if (this.cfg.multicastGroup) {
287
+ try {
288
+ this.socket.addMembership(this.cfg.multicastGroup, this.cfg.multicastIface || void 0);
289
+ log.info({ group: this.cfg.multicastGroup, iface: this.cfg.multicastIface || "auto" }, "joined multicast group");
290
+ } catch (err) {
291
+ log.error({ err, group: this.cfg.multicastGroup }, "failed to join multicast group");
292
+ }
293
+ }
294
+ this.resetState({ transport: "udp", port });
295
+ log.info({ port, multicast: this.cfg.multicastGroup || "disabled", airport: this.cfg.airportId ?? "unscoped" }, "asterix UDP listener started");
296
+ });
297
+ }
298
+ startTcp(host, port) {
299
+ void this.closeSockets();
300
+ this.shouldRun = true;
301
+ this.connectTcp(host, port);
302
+ }
303
+ connectTcp(host, port) {
304
+ this.tcpBuffer = Buffer.alloc(0);
305
+ const sock = tcpConnect({ host, port });
306
+ this.tcpSocket = sock;
307
+ sock.on("connect", () => {
308
+ this.resetState({ transport: "tcp", port, remote: `${host}:${port}` });
309
+ log.info({ host, port, airport: this.cfg.airportId ?? "unscoped" }, "asterix TCP feed connected");
310
+ });
311
+ sock.on("data", (chunk) => {
312
+ this.tcpBuffer = Buffer.concat([this.tcpBuffer, chunk]);
313
+ while (this.tcpBuffer.length >= 3) {
314
+ const len = this.tcpBuffer[1] << 8 | this.tcpBuffer[2];
315
+ if (len < 3) {
316
+ this.stats.packetsRejected++;
317
+ this.tcpBuffer = this.tcpBuffer.subarray(1);
318
+ continue;
319
+ }
320
+ if (this.tcpBuffer.length < len) break;
321
+ this.processDatagram(this.tcpBuffer.subarray(0, len));
322
+ this.tcpBuffer = this.tcpBuffer.subarray(len);
323
+ }
324
+ if (this.tcpBuffer.length > TCP_MAX_BUFFER) {
325
+ log.warn({ buffered: this.tcpBuffer.length }, "TCP buffer overflow \u2014 resyncing");
326
+ this.stats.packetsRejected++;
327
+ this.tcpBuffer = Buffer.alloc(0);
328
+ }
329
+ });
330
+ const scheduleReconnect = (why) => {
331
+ this.tcpSocket = null;
332
+ this.stats.running = false;
333
+ if (!this.shouldRun || this.tcpReconnectTimer) return;
334
+ log.warn({ host, port, why }, `asterix TCP feed disconnected \u2014 retrying in ${TCP_RECONNECT_MS / 1e3}s`);
335
+ this.tcpReconnectTimer = setTimeout(() => {
336
+ this.tcpReconnectTimer = null;
337
+ if (this.shouldRun) this.connectTcp(host, port);
338
+ }, TCP_RECONNECT_MS);
339
+ };
340
+ sock.on("error", (err) => {
341
+ this.stats.lastError = err.message;
342
+ scheduleReconnect(err.message);
343
+ });
344
+ sock.on("close", () => scheduleReconnect("closed"));
345
+ }
346
+ async closeSockets() {
347
+ if (this.tcpReconnectTimer) {
348
+ clearTimeout(this.tcpReconnectTimer);
349
+ this.tcpReconnectTimer = null;
350
+ }
351
+ if (this.socket) {
352
+ this.socket.close();
353
+ this.socket = null;
354
+ }
355
+ if (this.tcpSocket) {
356
+ this.tcpSocket.destroy();
357
+ this.tcpSocket = null;
358
+ }
359
+ }
360
+ emitDropped(icao24, trackId, reason) {
361
+ const dropped = {
362
+ ...this.makeFused(
363
+ trackId,
364
+ icao24,
365
+ void 0,
366
+ 0,
367
+ 0,
368
+ 0,
369
+ 0,
370
+ 0,
371
+ 0,
372
+ "asterix-cat021",
373
+ { ...this.meta(), droppedAt: Date.now(), reason }
374
+ ),
375
+ lifecycle: "dropped"
376
+ };
377
+ this.emit({ kind: "track", event: dropped });
378
+ }
379
+ /** Evict track entries idle longer than TRACK_MAP_TTL_MS, emitting a
380
+ * "dropped" lifecycle event for each. */
381
+ sweepTrackMap() {
382
+ const cutoff = Date.now() - TRACK_MAP_TTL_MS;
383
+ for (const [icao24, entry] of this.trackMap) {
384
+ if (entry.lastSeenAt >= cutoff) continue;
385
+ this.trackMap.delete(icao24);
386
+ this.emitDropped(icao24, entry.trackId, "stale");
387
+ }
388
+ }
389
+ async stop() {
390
+ this.shouldRun = false;
391
+ await this.closeSockets();
392
+ if (this.sweepTimer) {
393
+ clearInterval(this.sweepTimer);
394
+ this.sweepTimer = null;
395
+ }
396
+ for (const [icao24, entry] of this.trackMap) this.emitDropped(icao24, entry.trackId, "stopped");
397
+ this.trackMap.clear();
398
+ this.stats.running = false;
399
+ this.emit = () => {
400
+ };
401
+ log.info({ transport: this.stats.transport }, "asterix feed stopped");
402
+ }
403
+ };
404
+
405
+ // src/feed/aftn.ts
406
+ import { createSocket as createSocket2 } from "dgram";
407
+ import { connect as tcpConnect2 } from "net";
408
+ import { parseAftn, splitAftnStream } from "@aeroswarm/codec";
409
+ import { createLogger as createLogger2, createNanoid as createNanoid2 } from "@aeroswarm/core";
410
+ var log2 = createLogger2("ingest:aftn");
411
+ var TCP_RECONNECT_MS2 = 5e3;
412
+ var TCP_MAX_BUFFER2 = 1e6;
413
+ var AftnFeedSource = class {
414
+ constructor(cfg = { port: 0 }) {
415
+ this.cfg = cfg;
416
+ }
417
+ cfg;
418
+ stats = {
419
+ running: false,
420
+ transport: "udp",
421
+ port: 0,
422
+ remote: null,
423
+ packetsReceived: 0,
424
+ packetsForwarded: 0,
425
+ packetsRejected: 0,
426
+ packetsSkipped: 0,
427
+ packetsUndecoded: 0,
428
+ lastMessage: null,
429
+ lastError: null
430
+ };
431
+ socket = null;
432
+ tcpSocket = null;
433
+ tcpBuffer = "";
434
+ tcpReconnectTimer = null;
435
+ shouldRun = false;
436
+ emit = () => {
437
+ };
438
+ sourceId() {
439
+ return this.cfg.sourceId ?? "ingest";
440
+ }
441
+ resetState(patch) {
442
+ Object.assign(this.stats, {
443
+ running: true,
444
+ transport: "udp",
445
+ port: 0,
446
+ remote: null,
447
+ packetsReceived: 0,
448
+ packetsForwarded: 0,
449
+ packetsRejected: 0,
450
+ packetsSkipped: 0,
451
+ packetsUndecoded: 0,
452
+ lastMessage: null,
453
+ lastError: null,
454
+ ...patch
455
+ });
456
+ }
457
+ /** Parse one raw message and emit it. */
458
+ processMessage(raw) {
459
+ this.stats.packetsReceived++;
460
+ const now = Date.now();
461
+ const parsed = parseAftn(raw, { now });
462
+ if (!parsed) {
463
+ this.stats.packetsUndecoded++;
464
+ if (this.stats.packetsUndecoded === 1 || this.stats.packetsUndecoded % 1e3 === 0) {
465
+ const head = (typeof raw === "string" ? Buffer.from(raw, "latin1") : raw).subarray(0, 64).toString("latin1");
466
+ log2.warn({ count: this.stats.packetsUndecoded, head }, "no ATS message found in payload");
467
+ }
468
+ return;
469
+ }
470
+ if (!parsed.callsign) {
471
+ this.stats.packetsSkipped++;
472
+ return;
473
+ }
474
+ this.stats.lastMessage = {
475
+ type: parsed.type,
476
+ callsign: parsed.callsign,
477
+ adep: parsed.adep,
478
+ ades: parsed.ades,
479
+ at: now
480
+ };
481
+ this.emit({
482
+ kind: "flightplan",
483
+ event: {
484
+ messageId: createNanoid2(12),
485
+ sourceId: this.sourceId(),
486
+ ...this.cfg.feedId && { feedId: this.cfg.feedId },
487
+ ...this.cfg.airportId && { airportId: this.cfg.airportId },
488
+ receivedAt: now,
489
+ message: parsed
490
+ }
491
+ });
492
+ this.stats.packetsForwarded++;
493
+ }
494
+ /** A UDP datagram normally holds one message; tolerate several back to back. */
495
+ processDatagram(buf) {
496
+ const text = buf.toString("latin1");
497
+ const { messages, rest } = splitAftnStream(text);
498
+ const all = messages.length > 0 ? [...messages, ...rest.includes("(") ? [rest] : []] : [text];
499
+ for (const m of all) this.processMessage(m);
500
+ }
501
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
502
+ start(onEvent) {
503
+ this.emit = onEvent;
504
+ if (this.cfg.transport === "tcp") {
505
+ if (!this.cfg.host) throw new Error("AftnFeedSource: host is required for TCP transport");
506
+ this.startTcp(this.cfg.host, this.cfg.port);
507
+ } else {
508
+ this.startUdp(this.cfg.port);
509
+ }
510
+ }
511
+ startUdp(port) {
512
+ this.closeSockets();
513
+ this.shouldRun = true;
514
+ this.socket = createSocket2({ type: "udp4", reuseAddr: true });
515
+ this.socket.on("message", (buf) => this.processDatagram(buf));
516
+ this.socket.on("error", (err) => {
517
+ this.stats.lastError = err.message;
518
+ log2.warn({ err: err.message }, "aftn UDP socket error");
519
+ });
520
+ this.socket.bind(port, "0.0.0.0", () => {
521
+ if (this.cfg.multicastGroup) {
522
+ try {
523
+ this.socket.addMembership(this.cfg.multicastGroup, this.cfg.multicastIface || void 0);
524
+ log2.info({ group: this.cfg.multicastGroup, iface: this.cfg.multicastIface || "auto" }, "joined multicast group");
525
+ } catch (err) {
526
+ log2.error({ err, group: this.cfg.multicastGroup }, "failed to join multicast group");
527
+ }
528
+ }
529
+ this.resetState({ transport: "udp", port });
530
+ log2.info({ port, airport: this.cfg.airportId ?? "unscoped" }, "aftn UDP listener started");
531
+ });
532
+ }
533
+ startTcp(host, port) {
534
+ this.closeSockets();
535
+ this.shouldRun = true;
536
+ this.connectTcp(host, port);
537
+ }
538
+ connectTcp(host, port) {
539
+ this.tcpBuffer = "";
540
+ const sock = tcpConnect2({ host, port });
541
+ this.tcpSocket = sock;
542
+ sock.on("connect", () => {
543
+ this.resetState({ transport: "tcp", port, remote: `${host}:${port}` });
544
+ log2.info({ host, port, airport: this.cfg.airportId ?? "unscoped" }, "aftn TCP feed connected");
545
+ });
546
+ sock.on("data", (chunk) => {
547
+ const { messages, rest } = splitAftnStream(this.tcpBuffer + chunk.toString("latin1"));
548
+ this.tcpBuffer = rest;
549
+ for (const m of messages) this.processMessage(m);
550
+ if (this.tcpBuffer.length > TCP_MAX_BUFFER2) {
551
+ log2.warn({ buffered: this.tcpBuffer.length }, "aftn TCP buffer overflow \u2014 resyncing");
552
+ this.stats.packetsRejected++;
553
+ this.tcpBuffer = "";
554
+ }
555
+ });
556
+ const scheduleReconnect = (why) => {
557
+ this.tcpSocket = null;
558
+ this.stats.running = false;
559
+ if (!this.shouldRun || this.tcpReconnectTimer) return;
560
+ log2.warn({ host, port, why }, `aftn TCP feed disconnected \u2014 retrying in ${TCP_RECONNECT_MS2 / 1e3}s`);
561
+ this.tcpReconnectTimer = setTimeout(() => {
562
+ this.tcpReconnectTimer = null;
563
+ if (this.shouldRun) this.connectTcp(host, port);
564
+ }, TCP_RECONNECT_MS2);
565
+ };
566
+ sock.on("error", (err) => {
567
+ this.stats.lastError = err.message;
568
+ scheduleReconnect(err.message);
569
+ });
570
+ sock.on("close", () => scheduleReconnect("closed"));
571
+ }
572
+ closeSockets() {
573
+ if (this.tcpReconnectTimer) {
574
+ clearTimeout(this.tcpReconnectTimer);
575
+ this.tcpReconnectTimer = null;
576
+ }
577
+ if (this.socket) {
578
+ this.socket.close();
579
+ this.socket = null;
580
+ }
581
+ if (this.tcpSocket) {
582
+ this.tcpSocket.destroy();
583
+ this.tcpSocket = null;
584
+ }
585
+ }
586
+ async stop() {
587
+ this.shouldRun = false;
588
+ this.closeSockets();
589
+ this.stats.running = false;
590
+ this.emit = () => {
591
+ };
592
+ log2.info({ transport: this.stats.transport }, "aftn feed stopped");
593
+ }
594
+ };
595
+
596
+ // src/index.ts
597
+ function createFeedSource(cfg) {
598
+ const { protocol: _protocol, ...rest } = cfg;
599
+ return cfg.protocol === "asterix" ? new AsterixFeedSource(rest) : new AftnFeedSource(rest);
600
+ }
601
+ export {
602
+ AftnFeedSource,
603
+ AsterixFeedSource,
604
+ createFeedSource
605
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ingest.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest.test.d.ts","sourceRoot":"","sources":["../src/ingest.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,29 @@
1
+ import type { IngestEventHandler, IngestSource, IngestSourceStats } from "./types.js";
2
+ export interface KafkaSourceConfig {
3
+ brokers: string[];
4
+ /** Consumer group — use one per backend so each gets the full stream */
5
+ groupId: string;
6
+ /** Topics to subscribe; default = the fused/flight-plan/health pipeline */
7
+ topics?: string[];
8
+ fromBeginning?: boolean;
9
+ clientId?: string;
10
+ /** Passed through to kafkajs Kafka({ ssl, sasl }) verbatim */
11
+ ssl?: unknown;
12
+ sasl?: unknown;
13
+ }
14
+ /** AeroSwarm relay source: consumes the same Kafka topics client-service
15
+ * does — fused tracks, flight plans, source health — and re-emits them as
16
+ * IngestEvents. The most faithful "via AeroSwarm" path: standalone flight
17
+ * plans arrive even when no track correlates yet.
18
+ *
19
+ * Requires the optional peer `kafkajs` — `import ... from "@aeroswarm/ingest/kafka"`. */
20
+ export declare class KafkaSource implements IngestSource {
21
+ private readonly cfg;
22
+ readonly stats: IngestSourceStats;
23
+ private consumer;
24
+ private emit;
25
+ constructor(cfg: KafkaSourceConfig);
26
+ start(onEvent: IngestEventHandler): Promise<void>;
27
+ stop(): Promise<void>;
28
+ }
29
+ //# sourceMappingURL=kafka.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kafka.d.ts","sourceRoot":"","sources":["../src/kafka.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAaD;;;;;0FAK0F;AAC1F,qBAAa,WAAY,YAAW,YAAY;IAUlC,OAAO,CAAC,QAAQ,CAAC,GAAG;IAThC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAI/B;IAEF,OAAO,CAAC,QAAQ,CAAkD;IAClE,OAAO,CAAC,IAAI,CAAgC;gBAEf,GAAG,EAAE,iBAAiB;IAE7C,KAAK,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6CjD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ5B"}
package/dist/kafka.js ADDED
@@ -0,0 +1,81 @@
1
+ // src/kafka.ts
2
+ import { createLogger, TOPICS } from "@aeroswarm/core";
3
+ var log = createLogger("ingest:kafka");
4
+ var DEFAULT_TOPICS = [TOPICS.FUSED, TOPICS.FLIGHT_PLAN, TOPICS.HEALTH];
5
+ var KafkaSource = class {
6
+ constructor(cfg) {
7
+ this.cfg = cfg;
8
+ }
9
+ cfg;
10
+ stats = {
11
+ running: false,
12
+ transport: "kafka",
13
+ port: 0,
14
+ remote: null,
15
+ packetsReceived: 0,
16
+ packetsForwarded: 0,
17
+ packetsRejected: 0,
18
+ packetsSkipped: 0,
19
+ packetsUndecoded: 0,
20
+ lastError: null
21
+ };
22
+ consumer = null;
23
+ emit = () => {
24
+ };
25
+ async start(onEvent) {
26
+ this.emit = onEvent;
27
+ const { Kafka } = await import("kafkajs");
28
+ const kafka = new Kafka({
29
+ clientId: this.cfg.clientId ?? "aeroswarm-ingest",
30
+ brokers: this.cfg.brokers,
31
+ ...this.cfg.ssl !== void 0 && { ssl: this.cfg.ssl },
32
+ ...this.cfg.sasl !== void 0 && { sasl: this.cfg.sasl }
33
+ });
34
+ const topics = this.cfg.topics ?? DEFAULT_TOPICS;
35
+ this.consumer = kafka.consumer({ groupId: this.cfg.groupId });
36
+ await this.consumer.connect();
37
+ await this.consumer.subscribe({ topics, fromBeginning: this.cfg.fromBeginning ?? false });
38
+ await this.consumer.run({
39
+ eachMessage: async ({ topic, message }) => {
40
+ this.stats.packetsReceived++;
41
+ if (!message.value) {
42
+ this.stats.packetsSkipped++;
43
+ return;
44
+ }
45
+ try {
46
+ const data = JSON.parse(message.value.toString());
47
+ if (topic === TOPICS.FUSED) {
48
+ this.emit({ kind: "track", event: data });
49
+ } else if (topic === TOPICS.FLIGHT_PLAN) {
50
+ this.emit({ kind: "flightplan", event: data });
51
+ } else if (topic === TOPICS.HEALTH) {
52
+ this.emit({ kind: "health", event: data });
53
+ } else {
54
+ this.emit({ kind: "raw", topic, key: "", fields: data });
55
+ this.stats.packetsReceived--;
56
+ return;
57
+ }
58
+ this.stats.packetsForwarded++;
59
+ } catch (err) {
60
+ this.stats.packetsRejected++;
61
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
62
+ }
63
+ }
64
+ });
65
+ this.stats.running = true;
66
+ this.stats.remote = this.cfg.brokers.join(",");
67
+ log.info({ brokers: this.cfg.brokers, groupId: this.cfg.groupId, topics }, "kafka source started");
68
+ }
69
+ async stop() {
70
+ if (this.consumer) {
71
+ await this.consumer.disconnect();
72
+ this.consumer = null;
73
+ }
74
+ this.stats.running = false;
75
+ this.emit = () => {
76
+ };
77
+ }
78
+ };
79
+ export {
80
+ KafkaSource
81
+ };
@@ -0,0 +1,52 @@
1
+ import type { CurrentTrack, FlightPlanEvent, FusedTrackEvent, SourceHealthEvent } from "@aeroswarm/core";
2
+ /** Unified event emitted by every ingest source — direct feeds (UDP/TCP) and
3
+ * AeroSwarm relays (Kafka/WS) alike. Consumers switch data path by config
4
+ * only; the event shape downstream never changes.
5
+ *
6
+ * `track` carries a FusedTrackEvent from direct feeds / Kafka, and a
7
+ * CurrentTrack from the WS live stream — narrow with `"position" in ev.event`
8
+ * (FusedTrackEvent) vs `"lat" in ev.event` (CurrentTrack). */
9
+ export type IngestEvent = {
10
+ kind: "track";
11
+ event: FusedTrackEvent | CurrentTrack;
12
+ } | {
13
+ kind: "trackRemoved";
14
+ trackId: string;
15
+ reason: string;
16
+ } | {
17
+ kind: "flightplan";
18
+ event: FlightPlanEvent;
19
+ } | {
20
+ kind: "health";
21
+ event: SourceHealthEvent;
22
+ } | {
23
+ kind: "raw";
24
+ topic: string;
25
+ key: string;
26
+ fields: Record<string, unknown>;
27
+ } | {
28
+ kind: "status";
29
+ status: string;
30
+ reason?: string;
31
+ };
32
+ export type IngestEventHandler = (event: IngestEvent) => void;
33
+ /** Per-source counters, kept identical in name/shape to the feed stats the
34
+ * simulator-service exposes to the dashboard today. */
35
+ export interface IngestSourceStats {
36
+ running: boolean;
37
+ transport: "udp" | "tcp" | "kafka" | "ws";
38
+ port: number;
39
+ remote: string | null;
40
+ packetsReceived: number;
41
+ packetsForwarded: number;
42
+ packetsRejected: number;
43
+ packetsSkipped: number;
44
+ packetsUndecoded: number;
45
+ lastError: string | null;
46
+ }
47
+ export interface IngestSource {
48
+ readonly stats: IngestSourceStats;
49
+ start(onEvent: IngestEventHandler): void | Promise<void>;
50
+ stop(): Promise<void>;
51
+ }
52
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,eAAe,EACf,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;+DAM+D;AAC/D,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,eAAe,GAAG,YAAY,CAAA;CAAE,GACxD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,eAAe,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC5E;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;AAE9D;wDACwD;AACxD,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,KAAK,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,CAAC;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,KAAK,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB"}
package/dist/ws.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { IngestEventHandler, IngestSource, IngestSourceStats } from "./types.js";
2
+ /** Minimal structural WebSocket — satisfied by the browser/Node-22 global,
3
+ * the `ws` package, and undici's WebSocket alike. */
4
+ export interface WebSocketLike {
5
+ readonly readyState: number;
6
+ send(data: string): void;
7
+ close(code?: number, reason?: string): void;
8
+ onopen: (() => void) | null;
9
+ onmessage: ((ev: {
10
+ data: unknown;
11
+ }) => void) | null;
12
+ onclose: ((ev: {
13
+ code?: number;
14
+ reason?: string;
15
+ }) => void) | null;
16
+ onerror: ((ev: unknown) => void) | null;
17
+ }
18
+ export type WebSocketCtor = new (url: string) => WebSocketLike;
19
+ export interface WsSourceConfig {
20
+ /** ws(s)://<host>/ws/tracks/live — token is appended as ?token= unless the
21
+ * URL already carries one */
22
+ url: string;
23
+ token?: string;
24
+ /** Sent in the subscribe message; a source token overrides it server-side */
25
+ airportId?: string;
26
+ bbox?: [number, number, number, number];
27
+ minConfidence?: number;
28
+ maxUpdateHz?: number;
29
+ maxTracks?: number;
30
+ reconnectMs?: number;
31
+ /** WebSocket constructor override — pass `ws`'s WebSocket on Node < 22 */
32
+ WebSocketImpl?: WebSocketCtor;
33
+ }
34
+ /** AeroSwarm relay source over the live WebSocket (`/ws/tracks/live`):
35
+ * snapshot + diff stream of CurrentTracks — including `track.flightPlan`
36
+ * when a plan correlates. Track-only by design: standalone flight plans
37
+ * (no radar contact yet) and source health never cross this channel — use
38
+ * KafkaSource for the full pipeline. `import ... from "@aeroswarm/ingest/ws"`. */
39
+ export declare class WsSource implements IngestSource {
40
+ private readonly cfg;
41
+ readonly stats: IngestSourceStats;
42
+ private ws;
43
+ private reconnectTimer;
44
+ private shouldRun;
45
+ private emit;
46
+ constructor(cfg: WsSourceConfig);
47
+ private wsUrl;
48
+ start(onEvent: IngestEventHandler): void;
49
+ private connect;
50
+ stop(): Promise<void>;
51
+ }
52
+ //# sourceMappingURL=ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws.d.ts","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAMtF;sDACsD;AACtD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,MAAM,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,CAAC,CAAC,EAAE,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACpD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACnE,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;CACzC;AACD,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,EAAE,MAAM,KAAK,aAAa,CAAC;AAE/D,MAAM,WAAW,cAAc;IAC7B;kCAC8B;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;AASD;;;;mFAImF;AACnF,qBAAa,QAAS,YAAW,YAAY;IAY/B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAXhC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAI/B;IAEF,OAAO,CAAC,EAAE,CAA8B;IACxC,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,IAAI,CAAgC;gBAEf,GAAG,EAAE,cAAc;IAEhD,OAAO,CAAC,KAAK;IAMb,KAAK,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI;IAMxC,OAAO,CAAC,OAAO;IAsET,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ5B"}
package/dist/ws.js ADDED
@@ -0,0 +1,117 @@
1
+ // src/ws.ts
2
+ import { createLogger } from "@aeroswarm/core";
3
+ var log = createLogger("ingest:ws");
4
+ var DEFAULT_RECONNECT_MS = 5e3;
5
+ var WsSource = class {
6
+ constructor(cfg) {
7
+ this.cfg = cfg;
8
+ }
9
+ cfg;
10
+ stats = {
11
+ running: false,
12
+ transport: "ws",
13
+ port: 0,
14
+ remote: null,
15
+ packetsReceived: 0,
16
+ packetsForwarded: 0,
17
+ packetsRejected: 0,
18
+ packetsSkipped: 0,
19
+ packetsUndecoded: 0,
20
+ lastError: null
21
+ };
22
+ ws = null;
23
+ reconnectTimer = null;
24
+ shouldRun = false;
25
+ emit = () => {
26
+ };
27
+ wsUrl() {
28
+ const u = new URL(this.cfg.url);
29
+ if (this.cfg.token && !u.searchParams.has("token")) u.searchParams.set("token", this.cfg.token);
30
+ return u.toString();
31
+ }
32
+ start(onEvent) {
33
+ this.emit = onEvent;
34
+ this.shouldRun = true;
35
+ this.connect();
36
+ }
37
+ connect() {
38
+ const Impl = this.cfg.WebSocketImpl ?? globalThis.WebSocket;
39
+ if (!Impl) {
40
+ this.stats.lastError = "No WebSocket implementation \u2014 pass WebSocketImpl (e.g. from the `ws` package) on Node < 22";
41
+ log.error(this.stats.lastError);
42
+ return;
43
+ }
44
+ const url = this.wsUrl();
45
+ this.stats.remote = url.replace(/token=[^&]+/, "token=***");
46
+ const ws = new Impl(url);
47
+ this.ws = ws;
48
+ ws.onopen = () => {
49
+ this.stats.running = true;
50
+ this.stats.lastError = null;
51
+ ws.send(JSON.stringify({
52
+ type: "subscribe",
53
+ encoding: "json",
54
+ ...this.cfg.airportId && { airportId: this.cfg.airportId },
55
+ ...this.cfg.bbox && { bbox: this.cfg.bbox },
56
+ ...this.cfg.minConfidence !== void 0 && { minConfidence: this.cfg.minConfidence },
57
+ ...this.cfg.maxUpdateHz !== void 0 && { maxUpdateHz: this.cfg.maxUpdateHz },
58
+ ...this.cfg.maxTracks !== void 0 && { maxTracks: this.cfg.maxTracks }
59
+ }));
60
+ log.info({ url: this.stats.remote }, "ws source connected");
61
+ };
62
+ ws.onmessage = (ev) => {
63
+ this.stats.packetsReceived++;
64
+ try {
65
+ const env = JSON.parse(String(ev.data));
66
+ if (env.type === "snapshot") {
67
+ for (const track of env.payload.tracks) this.emit({ kind: "track", event: track });
68
+ this.stats.packetsForwarded += env.payload.tracks.length;
69
+ } else if (env.type === "track.updated") {
70
+ this.emit({ kind: "track", event: env.payload.track });
71
+ this.stats.packetsForwarded++;
72
+ } else if (env.type === "track.removed") {
73
+ this.emit({ kind: "trackRemoved", trackId: env.payload.trackId, reason: env.payload.reason });
74
+ } else if (env.type === "source.status") {
75
+ this.emit({ kind: "status", status: env.payload.status, reason: env.payload.sourceId });
76
+ } else if (env.type === "stream.degraded" || env.type === "stream.error") {
77
+ this.emit({ kind: "status", status: env.type, reason: String(env.payload?.["reason"] ?? env.payload?.["code"] ?? "") });
78
+ }
79
+ } catch (err) {
80
+ this.stats.packetsRejected++;
81
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
82
+ }
83
+ };
84
+ ws.onerror = (ev) => {
85
+ this.stats.lastError = ev instanceof Error ? ev.message : "websocket error";
86
+ };
87
+ ws.onclose = (ev) => {
88
+ this.stats.running = false;
89
+ this.ws = null;
90
+ if (!this.shouldRun || this.reconnectTimer) return;
91
+ const ms = this.cfg.reconnectMs ?? DEFAULT_RECONNECT_MS;
92
+ log.warn({ code: ev.code, why: ev.reason }, `ws source disconnected \u2014 retrying in ${ms / 1e3}s`);
93
+ this.reconnectTimer = setTimeout(() => {
94
+ this.reconnectTimer = null;
95
+ if (this.shouldRun) this.connect();
96
+ }, ms);
97
+ };
98
+ }
99
+ async stop() {
100
+ this.shouldRun = false;
101
+ if (this.reconnectTimer) {
102
+ clearTimeout(this.reconnectTimer);
103
+ this.reconnectTimer = null;
104
+ }
105
+ if (this.ws) {
106
+ this.ws.close();
107
+ this.ws = null;
108
+ }
109
+ this.stats.running = false;
110
+ this.emit = () => {
111
+ };
112
+ log.info("ws source stopped");
113
+ }
114
+ };
115
+ export {
116
+ WsSource
117
+ };
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@aeroswarm/ingest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Switchable surveillance ingest sources — direct ASTERIX/AFTN feeds (UDP/TCP) or AeroSwarm relays (Kafka/WS), one event stream",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "main": "./dist/index.js"
13
+ },
14
+ "./kafka": {
15
+ "import": "./dist/kafka.js",
16
+ "types": "./dist/kafka.d.ts",
17
+ "main": "./dist/kafka.js"
18
+ },
19
+ "./ws": {
20
+ "import": "./dist/ws.js",
21
+ "types": "./dist/ws.d.ts",
22
+ "main": "./dist/ws.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup src/index.ts src/kafka.ts src/ws.ts --format esm --clean && tsc --emitDeclarationOnly",
34
+ "dev": "tsup src/index.ts src/kafka.ts src/ws.ts --format esm --dts --watch",
35
+ "test": "vitest run --passWithNoTests",
36
+ "typecheck": "tsc --noEmit",
37
+ "clean": "rm -rf dist",
38
+ "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build",
39
+ "release": "release-it",
40
+ "release:dry": "release-it --dry-run",
41
+ "release:patch": "release-it patch --no-git.requireCleanWorkingDir",
42
+ "release:minor": "release-it minor --no-git.requireCleanWorkingDir",
43
+ "release:major": "release-it major --no-git.requireCleanWorkingDir"
44
+ },
45
+ "dependencies": {
46
+ "@aeroswarm/codec": "^0.1.8",
47
+ "@aeroswarm/core": "^0.2.9"
48
+ },
49
+ "peerDependencies": {
50
+ "kafkajs": "^2.2.4"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "kafkajs": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "kafkajs": "^2.2.4",
59
+ "release-it": "^18.1.2",
60
+ "tsup": "^8.5.1",
61
+ "vitest": "^4.1.6"
62
+ }
63
+ }