@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 +72 -2
- package/dist/feed/aftn.d.ts +50 -0
- package/dist/feed/aftn.d.ts.map +1 -0
- package/dist/feed/asterix.d.ts +76 -0
- package/dist/feed/asterix.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +605 -0
- package/dist/ingest.test.d.ts +2 -0
- package/dist/ingest.test.d.ts.map +1 -0
- package/dist/kafka.d.ts +29 -0
- package/dist/kafka.d.ts.map +1 -0
- package/dist/kafka.js +81 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/ws.d.ts +52 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +117 -0
- package/package.json +61 -4
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aeroswarm/ingest
|
|
2
2
|
|
|
3
|
-
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"ingest.test.d.ts","sourceRoot":"","sources":["../src/ingest.test.ts"],"names":[],"mappings":""}
|
package/dist/kafka.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
package/dist/ws.d.ts.map
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|