@aeroswarm/live-server 0.0.0-stage → 0.1.2
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 +177 -2
- package/dist/index.d.ts +301 -0
- package/dist/index.js +1173 -0
- package/package.json +45 -6
package/README.md
CHANGED
|
@@ -1,3 +1,178 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aeroswarm/live-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The live-picture runtime AeroSwarm's `client-service` uses, packaged so a backend can serve the
|
|
4
|
+
**same live WebSocket** (`/ws/tracks/live`) without running AeroSwarm's services. `@aeroswarm/client`
|
|
5
|
+
connects to it unchanged.
|
|
6
|
+
|
|
7
|
+
| Piece | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `createTrackState` | Current tracks: `active → stale → lost → removed`, de-dupe by Mode-S address, flight-plan attachment, filtered snapshots |
|
|
10
|
+
| `createFlightPlanStore` | AFTN messages folded into flight plans; correlation to live tracks (Mode-S, then callsign; the leg in progress wins) |
|
|
11
|
+
| `createMovementDetector` | Takeoff / landing detection from consecutive track samples |
|
|
12
|
+
| `createLiveServer` | WebSocket handlers speaking the AeroSwarm envelope protocol |
|
|
13
|
+
|
|
14
|
+
No I/O: no database, Kafka, Redis or auth. The host injects `authenticate` and persists from the
|
|
15
|
+
events these classes emit. Depends only on `@aeroswarm/core` (bundled) and `zod`.
|
|
16
|
+
|
|
17
|
+
## Wiring
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createFlightPlanStore, createTrackState, createLiveServer, createMovementDetector } from "@aeroswarm/live-server";
|
|
21
|
+
|
|
22
|
+
const plans = createFlightPlanStore();
|
|
23
|
+
const state = createTrackState({ attachFlightPlan: plans.attach });
|
|
24
|
+
plans.on("changed", (plan) => {
|
|
25
|
+
state.refreshFlightPlans((t) => plans.matches(plan, t)); // push the plan to tracks already on screen
|
|
26
|
+
persist(plan); // host's job
|
|
27
|
+
});
|
|
28
|
+
state.startSweep(); // recomputes stale/lost every 5 s; required, or tracks never go lost
|
|
29
|
+
plans.startSweep();
|
|
30
|
+
|
|
31
|
+
const live = createLiveServer({
|
|
32
|
+
state,
|
|
33
|
+
authenticate: async (token) => (await verify(token)) ? { airportId: "WARR" } : null,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// feeds (see @aeroswarm/ingest)
|
|
37
|
+
trackSource.start((ev) => { if (ev.kind === "track") state.ingest(ev.event as FusedTrackEvent); });
|
|
38
|
+
aftnSource.start((ev) => { if (ev.kind === "flightplan") plans.apply(ev.event); });
|
|
39
|
+
|
|
40
|
+
// movements
|
|
41
|
+
const movements = createMovementDetector();
|
|
42
|
+
state.on("update", (t) => {
|
|
43
|
+
const m = movements.update({ trackId: t.trackId, icao24: t.icao24, callsign: t.callsign, airportId: t.airportId,
|
|
44
|
+
at: t.lastSeenAt, lat: t.lat!, lon: t.lon!, altitudeFt: t.altitudeFt, groundSpeedKt: t.groundSpeedKt });
|
|
45
|
+
if (m) saveMovement(m);
|
|
46
|
+
});
|
|
47
|
+
state.on("removed", ({ trackId }) => movements.forget(trackId));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Bun
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
Bun.serve({
|
|
54
|
+
fetch(req, srv) {
|
|
55
|
+
const url = new URL(req.url);
|
|
56
|
+
if (url.pathname === "/ws/tracks/live")
|
|
57
|
+
return srv.upgrade(req, { data: { token: url.searchParams.get("token") } }) ? undefined : new Response("upgrade failed", { status: 400 });
|
|
58
|
+
// ...
|
|
59
|
+
},
|
|
60
|
+
websocket: {
|
|
61
|
+
open: (ws) => live.handlers.open(ws, { token: ws.data.token }),
|
|
62
|
+
message: (ws, raw) => live.handlers.message(ws, raw),
|
|
63
|
+
close: (ws) => live.handlers.close(ws),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `ws` library
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
wss.on("connection", (ws, req) => {
|
|
72
|
+
void live.handlers.open(ws, { token: new URL(req.url!, "http://x").searchParams.get("token") });
|
|
73
|
+
ws.on("message", (m) => live.handlers.message(ws, m.toString()));
|
|
74
|
+
ws.on("close", () => live.handlers.close(ws));
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Several sensors, one aircraft (fusion)
|
|
79
|
+
|
|
80
|
+
Radar, MLAT and ADS-B often see the same aircraft. Each feed makes its own
|
|
81
|
+
track id, and `TrackState` replaces a track whenever another id arrives for the
|
|
82
|
+
same `icao24`, so two feeds knock each other out on every update. Put the fuser
|
|
83
|
+
between the feeds and `state.ingest`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { createTrackFuser } from "@aeroswarm/live-server";
|
|
87
|
+
|
|
88
|
+
const fuser = createTrackFuser(); // or createTrackFuser({ spatial: true, ... })
|
|
89
|
+
|
|
90
|
+
trackSource.start((ev) => {
|
|
91
|
+
if (ev.kind !== "track") return;
|
|
92
|
+
const fused = fuser.ingest(ev.event as FusedTrackEvent); // null = nothing new to show
|
|
93
|
+
if (fused) { persist(fused); state.ingest(fused); }
|
|
94
|
+
});
|
|
95
|
+
state.on("removed", ({ trackId }) => fuser.forget(trackId));
|
|
96
|
+
setInterval(() => fuser.sweep(), 30_000); // forget sensors that went silent
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
What you get: one canonical `trackId` per aircraft, `sources` listing every
|
|
100
|
+
contributing sensor (`lifecycle: "merged"`), and the position of the preferred
|
|
101
|
+
sensor. A sensor that lets go of the track (`lifecycle: "dropped"`) does not
|
|
102
|
+
remove the aircraft while another sensor still sees it. With one sensor the
|
|
103
|
+
output is the same as without the fuser, with a stable track id.
|
|
104
|
+
|
|
105
|
+
**Matching.** By Mode-S address (`icao24`). Targets without one (surface
|
|
106
|
+
vehicles, SSR-only radar) get a synthetic id from sensor + track number
|
|
107
|
+
(`r50-176-42`), which means nothing to another sensor; they are only joined when
|
|
108
|
+
`spatial` is on, by distance and time. A candidate that is ambiguous (more than
|
|
109
|
+
one), or that carries a different Mode-S address, is never merged.
|
|
110
|
+
|
|
111
|
+
**Which sensor's position is shown.** The first match in the priority list for
|
|
112
|
+
the current state, where "on the ground" means some fresh report says
|
|
113
|
+
`onGround`. Entries are a protocol (`asterix-cat010`) or a feed's source id.
|
|
114
|
+
The default prefers MLAT in both lists, then ADS-B, the system track and radar.
|
|
115
|
+
A sensor silent for `maxSourceAgeMs` stops competing.
|
|
116
|
+
|
|
117
|
+
| Option | Default | |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `surfacePriority` | MLAT (CAT010/011/020), CAT021, CAT062, CAT048 | best first |
|
|
120
|
+
| `airbornePriority` | MLAT (CAT020/010/011), CAT021, CAT062, CAT048 | best first |
|
|
121
|
+
| `maxSourceAgeMs` | `5000` | silent sensor stops competing |
|
|
122
|
+
| `forgetAfterMs` | `600000` | sensor forgotten after this long silent |
|
|
123
|
+
| `spatial` | `false` | also match tracks that share no Mode-S address |
|
|
124
|
+
| `matchRadiusM` | `150` | spatial matching: distance |
|
|
125
|
+
| `matchMaxSkewMs` | `3000` | spatial matching: time difference |
|
|
126
|
+
| `disagreeM` | `300` | sensors further apart are counted as disagreeing |
|
|
127
|
+
| `now` | `Date.now` | clock, for tests |
|
|
128
|
+
|
|
129
|
+
`fuser.stats()` returns `{ aircraft, merged, spatialMatches, ambiguous,
|
|
130
|
+
disagreements }`. `confidence.sourceAgreement` on a merged track falls from 1 to
|
|
131
|
+
0 as its sensors drift apart.
|
|
132
|
+
|
|
133
|
+
Fusion applies to what the host feeds it: events that arrive some other way
|
|
134
|
+
(HTTP, Kafka) are not fused unless the host passes them through `ingest` too.
|
|
135
|
+
|
|
136
|
+
## Protocol
|
|
137
|
+
|
|
138
|
+
JSON envelopes `{ type, seq, serverTime, payload }`, `seq` strictly increasing across the server.
|
|
139
|
+
|
|
140
|
+
| Server sends | Payload |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `snapshot` | `{ tracks: CurrentTrack[] }`, on `subscribe` and `update-filter` |
|
|
143
|
+
| `track.updated` | `{ track }`, rate-limited per client per track (`maxUpdateHz`); status and flight-plan changes are never throttled |
|
|
144
|
+
| `track.removed` | `{ trackId, reason }`, reason is `dropped`, `superseded`, `lost`, `evicted` (over `maxTracks`) or `filtered` (left the client's filter) |
|
|
145
|
+
| `source.status` / `stream.degraded` | via `live.sourceStatus()` / `live.degraded()` |
|
|
146
|
+
| `stream.heartbeat` | every `heartbeatMs` (default 15 s) and once on open |
|
|
147
|
+
| `stream.error` | `{ code, message, recoverable: true }` for `BAD_JSON`, `BAD_SUBSCRIBE`, `BAD_FILTER` |
|
|
148
|
+
|
|
149
|
+
Client sends `subscribe` (`bbox`, `airportId`, `minConfidence`, `maxTracks`, `maxUpdateHz`, …) and
|
|
150
|
+
`update-filter`. Always JSON; `encoding: "msgpack"` is accepted but ignored.
|
|
151
|
+
|
|
152
|
+
Close codes: `4001` no token, `4003` rejected by `authenticate`, `1001` server stopping.
|
|
153
|
+
Messages that arrive before `authenticate` resolves are queued, not dropped.
|
|
154
|
+
|
|
155
|
+
### Airport scoping
|
|
156
|
+
|
|
157
|
+
`authenticate` returning `{ airportId }` pins the connection to that airport, whatever the client
|
|
158
|
+
subscribes with (a sensor token). Return `{}` for a viewer token that may ask for any airport.
|
|
159
|
+
|
|
160
|
+
## Status semantics
|
|
161
|
+
|
|
162
|
+
`toCurrentTrack` computes `status` once. `TrackState.sweep()` recomputes it from `lastSeenAt` and
|
|
163
|
+
emits only when it actually changes, so a quiet stretch does not spam clients. Defaults: stale after
|
|
164
|
+
15 s, lost after 90 s, removed after 30 min (`staleAfterMs`, `lostAfterMs`, `removeAfterMs`).
|
|
165
|
+
|
|
166
|
+
## Scaling
|
|
167
|
+
|
|
168
|
+
State is in-process memory: run one instance, or put a shared fan-out in front.
|
|
169
|
+
|
|
170
|
+
## Develop
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
pnpm --filter @aeroswarm/live-server test
|
|
174
|
+
pnpm --filter @aeroswarm/live-server build
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`test/client-integration.test.ts` runs the real `AeroWsClient` / `TrackCache` from
|
|
178
|
+
`@aeroswarm/client` against a live server over a socket, including `reconnect()`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { CurrentTrack, FusedTrackEvent, FlightPlan, FlightPlanEvent } from '@aeroswarm/core/types';
|
|
2
|
+
import { FusedTrackEvent as FusedTrackEvent$1 } from '@aeroswarm/core';
|
|
3
|
+
|
|
4
|
+
/** Minimal socket surface. Satisfied by Bun's `ServerWebSocket` and the `ws` library. */
|
|
5
|
+
interface LiveSocket {
|
|
6
|
+
send(data: string): unknown;
|
|
7
|
+
close(code?: number, reason?: string): unknown;
|
|
8
|
+
/** Optional; used for backpressure when present. */
|
|
9
|
+
getBufferedAmount?(): number;
|
|
10
|
+
}
|
|
11
|
+
interface Logger {
|
|
12
|
+
info(obj: unknown, msg?: string): void;
|
|
13
|
+
warn(obj: unknown, msg?: string): void;
|
|
14
|
+
error(obj: unknown, msg?: string): void;
|
|
15
|
+
}
|
|
16
|
+
type Bbox = [number, number, number, number];
|
|
17
|
+
interface TrackFilter {
|
|
18
|
+
airportId?: string;
|
|
19
|
+
bbox?: Bbox;
|
|
20
|
+
minConfidence?: number;
|
|
21
|
+
maxTracks?: number;
|
|
22
|
+
}
|
|
23
|
+
type RemoveReason = "dropped" | "superseded" | "lost" | "evicted" | "filtered";
|
|
24
|
+
interface UpdateInfo {
|
|
25
|
+
/** Bypass per-client rate limiting (status change, flight-plan change). */
|
|
26
|
+
force: boolean;
|
|
27
|
+
}
|
|
28
|
+
type TrackListener = (track: CurrentTrack, info: UpdateInfo) => void;
|
|
29
|
+
type RemovedListener = (ev: {
|
|
30
|
+
trackId: string;
|
|
31
|
+
reason: RemoveReason;
|
|
32
|
+
}) => void;
|
|
33
|
+
|
|
34
|
+
interface TrackStateOptions {
|
|
35
|
+
/** Default STALE_AFTER_MS (15 s). */
|
|
36
|
+
staleAfterMs?: number;
|
|
37
|
+
/** Default LOST_AFTER_MS (90 s). */
|
|
38
|
+
lostAfterMs?: number;
|
|
39
|
+
/** Tracks unseen this long are removed. 0 disables. Default 30 min. */
|
|
40
|
+
removeAfterMs?: number;
|
|
41
|
+
/** Drop tracks outside this box unless heading into it within the lookahead. */
|
|
42
|
+
interestBbox?: Bbox;
|
|
43
|
+
/** Minutes. Default 90. */
|
|
44
|
+
interestLookaheadMin?: number;
|
|
45
|
+
/** Clock, for tests. Default Date.now. */
|
|
46
|
+
now?: () => number;
|
|
47
|
+
/** Attach flight-plan data, e.g. `flightPlanStore.attach`. */
|
|
48
|
+
attachFlightPlan?: (track: CurrentTrack) => CurrentTrack;
|
|
49
|
+
}
|
|
50
|
+
/** Live picture of all tracks: lifecycle (active, stale, lost, removed),
|
|
51
|
+
* de-duplication by Mode-S address, flight-plan attachment. No I/O. */
|
|
52
|
+
declare class TrackState {
|
|
53
|
+
private readonly opts;
|
|
54
|
+
private readonly tracks;
|
|
55
|
+
private readonly byIcao24;
|
|
56
|
+
private readonly onUpdate;
|
|
57
|
+
private readonly onRemoved;
|
|
58
|
+
private readonly staleAfterMs;
|
|
59
|
+
private readonly lostAfterMs;
|
|
60
|
+
private readonly removeAfterMs;
|
|
61
|
+
private sweepTimer;
|
|
62
|
+
private readonly now;
|
|
63
|
+
constructor(opts?: TrackStateOptions);
|
|
64
|
+
private statusFor;
|
|
65
|
+
on(event: "update", fn: TrackListener): () => void;
|
|
66
|
+
on(event: "removed", fn: RemovedListener): () => void;
|
|
67
|
+
get size(): number;
|
|
68
|
+
get(trackId: string): CurrentTrack | undefined;
|
|
69
|
+
values(): IterableIterator<CurrentTrack>;
|
|
70
|
+
/** Apply one fused event. Returns the resulting track, or null when the event
|
|
71
|
+
* removed a track or was ignored. */
|
|
72
|
+
ingest(fused: FusedTrackEvent): CurrentTrack | null;
|
|
73
|
+
/** Remove a track (no-op if unknown). */
|
|
74
|
+
drop(trackId: string, reason?: RemoveReason): void;
|
|
75
|
+
/** Recompute `status` from `lastSeenAt` and remove long-gone tracks.
|
|
76
|
+
* Emits only on a real status change. Call every few seconds. */
|
|
77
|
+
sweep(now?: number): void;
|
|
78
|
+
/** Re-attach flight plans, emitting only for tracks whose plan data changed.
|
|
79
|
+
* Call when a plan changes so clients see it before the next radar update. */
|
|
80
|
+
refreshFlightPlans(match?: (track: CurrentTrack) => boolean): void;
|
|
81
|
+
snapshot(filter?: TrackFilter): CurrentTrack[];
|
|
82
|
+
/** Run `sweep` on an interval. Idempotent. */
|
|
83
|
+
startSweep(intervalMs?: number): void;
|
|
84
|
+
stopSweep(): void;
|
|
85
|
+
private remove;
|
|
86
|
+
private emitUpdate;
|
|
87
|
+
private inInterestZone;
|
|
88
|
+
}
|
|
89
|
+
declare function createTrackState(opts?: TrackStateOptions): TrackState;
|
|
90
|
+
|
|
91
|
+
interface FlightPlanStoreOptions {
|
|
92
|
+
/** Plans untouched this long are dropped from memory. Default 48 h. */
|
|
93
|
+
ttlMs?: number;
|
|
94
|
+
}
|
|
95
|
+
interface FlightPlanFilter {
|
|
96
|
+
airportId?: string;
|
|
97
|
+
status?: string;
|
|
98
|
+
callsign?: string;
|
|
99
|
+
limit?: number;
|
|
100
|
+
}
|
|
101
|
+
type Listener<T> = (value: T) => void;
|
|
102
|
+
/** Flight plans folded from AFTN messages, indexed for correlation with live
|
|
103
|
+
* tracks (Mode-S address first, then callsign). No I/O: the host persists via
|
|
104
|
+
* the `changed` event and restores with `load`. */
|
|
105
|
+
declare class FlightPlanStore {
|
|
106
|
+
private readonly plans;
|
|
107
|
+
private readonly byCallsign;
|
|
108
|
+
private readonly byIcao24;
|
|
109
|
+
private readonly onChanged;
|
|
110
|
+
private readonly onExpired;
|
|
111
|
+
private readonly ttlMs;
|
|
112
|
+
private sweepTimer;
|
|
113
|
+
constructor(opts?: FlightPlanStoreOptions);
|
|
114
|
+
/** `changed`: a plan was created or updated. `expired`: a plan id left memory. */
|
|
115
|
+
on(event: "changed", fn: Listener<FlightPlan>): () => void;
|
|
116
|
+
on(event: "expired", fn: Listener<string>): () => void;
|
|
117
|
+
get size(): number;
|
|
118
|
+
get(id: string): FlightPlan | undefined;
|
|
119
|
+
/** Fold one AFTN message into its flight. Returns the updated plan, or null if ignored. */
|
|
120
|
+
apply(ev: FlightPlanEvent): FlightPlan | null;
|
|
121
|
+
/** Restore plans (e.g. from a database after a restart). Does not emit `changed`. */
|
|
122
|
+
load(plans: FlightPlan[], now?: number): number;
|
|
123
|
+
/** Best plan for a live track. Every plan sharing the Mode-S address or
|
|
124
|
+
* callsign is a candidate (an airframe flies several legs a day); the leg
|
|
125
|
+
* in progress, or nearest in time, wins. Cancelled plans never match. */
|
|
126
|
+
find(track: Pick<CurrentTrack, "icao24" | "callsign">, now?: number): FlightPlan | undefined;
|
|
127
|
+
/** Track with its flight-plan summary attached; unchanged when none matches.
|
|
128
|
+
* An arrow property so it can be passed unbound, e.g. `{ attachFlightPlan: store.attach }`. */
|
|
129
|
+
readonly attach: (track: CurrentTrack) => CurrentTrack;
|
|
130
|
+
/** True if `plan` could correlate with `track`. Pair with `TrackState.refreshFlightPlans`. */
|
|
131
|
+
matches(plan: FlightPlan, track: Pick<CurrentTrack, "icao24" | "callsign">): boolean;
|
|
132
|
+
list(filter?: FlightPlanFilter): FlightPlan[];
|
|
133
|
+
/** Drop plans past the TTL; returns their ids. */
|
|
134
|
+
sweep(now?: number): string[];
|
|
135
|
+
startSweep(intervalMs?: number): void;
|
|
136
|
+
stopSweep(): void;
|
|
137
|
+
private index;
|
|
138
|
+
private unindex;
|
|
139
|
+
}
|
|
140
|
+
declare function createFlightPlanStore(opts?: FlightPlanStoreOptions): FlightPlanStore;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Takeoff / landing detection from consecutive track states.
|
|
144
|
+
*
|
|
145
|
+
* Pure, dependency-free state machine. One detector instance tracks many
|
|
146
|
+
* targets. Copied from process-service so backends running without
|
|
147
|
+
* AeroSwarm's services get identical detection.
|
|
148
|
+
*
|
|
149
|
+
* Heuristics (dual-threshold hysteresis so noisy altitude/on-ground
|
|
150
|
+
* flags don't double-count):
|
|
151
|
+
* ground → airborne at ≥ TAKEOFF_MIN_GS_KT and (onGround=false or
|
|
152
|
+
* alt > AIRBORNE_ALT_FT) ⇒ takeoff
|
|
153
|
+
* airborne → ground at ≤ LANDING_MAX_GS_KT and (onGround=true or
|
|
154
|
+
* alt < GROUND_ALT_FT) ⇒ landing
|
|
155
|
+
*/
|
|
156
|
+
type MovementType = "takeoff" | "landing";
|
|
157
|
+
interface MovementEvent {
|
|
158
|
+
trackId: string;
|
|
159
|
+
icao24: string | null;
|
|
160
|
+
callsign: string | null;
|
|
161
|
+
airportId?: string;
|
|
162
|
+
type: MovementType;
|
|
163
|
+
/** Unix epoch ms of the transition */
|
|
164
|
+
at: number;
|
|
165
|
+
lat: number;
|
|
166
|
+
lon: number;
|
|
167
|
+
}
|
|
168
|
+
interface MovementSample {
|
|
169
|
+
trackId: string;
|
|
170
|
+
icao24?: string | null;
|
|
171
|
+
callsign?: string | null;
|
|
172
|
+
airportId?: string;
|
|
173
|
+
at: number;
|
|
174
|
+
lat: number;
|
|
175
|
+
lon: number;
|
|
176
|
+
altitudeFt?: number | null;
|
|
177
|
+
groundSpeedKt?: number | null;
|
|
178
|
+
onGround?: boolean;
|
|
179
|
+
}
|
|
180
|
+
declare class MovementDetector {
|
|
181
|
+
private phases;
|
|
182
|
+
/** Feed one track state; returns a movement event on a transition. */
|
|
183
|
+
update(s: MovementSample): MovementEvent | null;
|
|
184
|
+
/** Forget one track (call when its track is removed). */
|
|
185
|
+
forget(trackId: string): void;
|
|
186
|
+
/** Forget tracks not updated within maxAgeMs (call periodically). */
|
|
187
|
+
sweep(maxAgeMs: number, now?: number): void;
|
|
188
|
+
get trackedCount(): number;
|
|
189
|
+
private makeEvent;
|
|
190
|
+
}
|
|
191
|
+
declare function createMovementDetector(): MovementDetector;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Several sensors (radar, MLAT, ...) often see the same aircraft. Each feed
|
|
195
|
+
* produces its own track id, so without this layer they replace one another
|
|
196
|
+
* on every update. The fuser keeps ONE canonical track per aircraft and picks
|
|
197
|
+
* which sensor's position to show.
|
|
198
|
+
*
|
|
199
|
+
* feed events ──► fuser.ingest ──► one event per aircraft ──► storage, live picture
|
|
200
|
+
*
|
|
201
|
+
* Matching: by Mode-S address when both have one. Tracks without it (surface
|
|
202
|
+
* vehicles, SSR-only radar: ids like "r50-176-42") only match across sensors
|
|
203
|
+
* when `spatial` is on, by position and time.
|
|
204
|
+
*/
|
|
205
|
+
interface FusionOptions {
|
|
206
|
+
/** Preferred sources while the aircraft is on the ground, best first. Each entry is a protocol ("asterix-cat010") or a source id. */
|
|
207
|
+
surfacePriority: string[];
|
|
208
|
+
/** Same, for an aircraft in the air. */
|
|
209
|
+
airbornePriority: string[];
|
|
210
|
+
/** A sensor silent for longer than this stops competing for the position. */
|
|
211
|
+
maxSourceAgeMs: number;
|
|
212
|
+
/** A sensor's contribution is forgotten after this long without an update. */
|
|
213
|
+
forgetAfterMs: number;
|
|
214
|
+
/** Also match tracks that share no Mode-S address, by distance and time. */
|
|
215
|
+
spatial: boolean;
|
|
216
|
+
matchRadiusM: number;
|
|
217
|
+
matchMaxSkewMs: number;
|
|
218
|
+
/** Two sensors further apart than this are reported as disagreeing. */
|
|
219
|
+
disagreeM: number;
|
|
220
|
+
now?: () => number;
|
|
221
|
+
}
|
|
222
|
+
/** MLAT first (CAT010 surface, CAT020 wide-area), then ADS-B, the system track, radar. */
|
|
223
|
+
declare const DEFAULT_SURFACE_PRIORITY: readonly string[];
|
|
224
|
+
/** MLAT is preferred in the air too. Put "asterix-cat062"/"asterix-cat048" first to prefer radar. */
|
|
225
|
+
declare const DEFAULT_AIRBORNE_PRIORITY: readonly string[];
|
|
226
|
+
/** Every option, with the defaults a host gets when it passes none. */
|
|
227
|
+
declare function defaultFusionOptions(): FusionOptions;
|
|
228
|
+
interface FusionStats {
|
|
229
|
+
/** Canonical tracks held. */
|
|
230
|
+
aircraft: number;
|
|
231
|
+
/** Of those, how many are fed by more than one sensor right now. */
|
|
232
|
+
merged: number;
|
|
233
|
+
/** Tracks joined by position because they shared no id. */
|
|
234
|
+
spatialMatches: number;
|
|
235
|
+
/** New tracks with several possible partners, left unmerged. */
|
|
236
|
+
ambiguous: number;
|
|
237
|
+
/** Emitted updates where the sensors were further apart than `disagreeM`. */
|
|
238
|
+
disagreements: number;
|
|
239
|
+
}
|
|
240
|
+
interface TrackFuser {
|
|
241
|
+
/** The event to store and show, or null when it adds nothing new. */
|
|
242
|
+
ingest(event: FusedTrackEvent$1): FusedTrackEvent$1 | null;
|
|
243
|
+
/** Forget a canonical track, e.g. when the live picture removed it. */
|
|
244
|
+
forget(trackId: string): void;
|
|
245
|
+
/** Drop sensors that went silent and tracks with none left. */
|
|
246
|
+
sweep(): void;
|
|
247
|
+
stats(): FusionStats;
|
|
248
|
+
}
|
|
249
|
+
/** Ids the ingest package builds from sensor + track number when there is no Mode-S address. */
|
|
250
|
+
declare const isSyntheticId: (icao24: string) => boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Options not given (or `undefined`) take their defaults, so a host passes only
|
|
253
|
+
* what it wants to change: `createTrackFuser({ spatial: true })`.
|
|
254
|
+
*/
|
|
255
|
+
declare function createTrackFuser(options?: Partial<FusionOptions>): TrackFuser;
|
|
256
|
+
|
|
257
|
+
/** Result of `authenticate`. `airportId` pins the connection to that airport
|
|
258
|
+
* (a sensor token); leave it undefined for viewer tokens that may request any. */
|
|
259
|
+
interface LiveAuth {
|
|
260
|
+
airportId?: string;
|
|
261
|
+
}
|
|
262
|
+
interface LiveServerOptions {
|
|
263
|
+
state: TrackState;
|
|
264
|
+
/** Return null to reject. A missing token is rejected (4001) before this is called. */
|
|
265
|
+
authenticate: (token: string) => Promise<LiveAuth | null> | LiveAuth | null;
|
|
266
|
+
/** Per-client, per-track push rate when the client does not ask for one. Default 10. */
|
|
267
|
+
defaultUpdateHz?: number;
|
|
268
|
+
/** Default 15 000. */
|
|
269
|
+
heartbeatMs?: number;
|
|
270
|
+
/** Skip non-forced deltas for sockets buffering more than this. Default 1 MiB. */
|
|
271
|
+
maxBufferedBytes?: number;
|
|
272
|
+
logger?: Logger;
|
|
273
|
+
}
|
|
274
|
+
interface LiveOpenContext {
|
|
275
|
+
/** Value of the `token` query parameter, read by the host at upgrade time. */
|
|
276
|
+
token: string | null;
|
|
277
|
+
}
|
|
278
|
+
interface LiveServerStats {
|
|
279
|
+
clients: number;
|
|
280
|
+
subscribed: number;
|
|
281
|
+
messagesSent: number;
|
|
282
|
+
deltasSkipped: number;
|
|
283
|
+
}
|
|
284
|
+
type LiveMessage = string | ArrayBuffer | Uint8Array;
|
|
285
|
+
interface LiveServer {
|
|
286
|
+
handlers: {
|
|
287
|
+
open(ws: LiveSocket, ctx: LiveOpenContext): Promise<void>;
|
|
288
|
+
message(ws: LiveSocket, raw: LiveMessage): void;
|
|
289
|
+
close(ws: LiveSocket): void;
|
|
290
|
+
};
|
|
291
|
+
/** Tell clients a data source changed health. */
|
|
292
|
+
sourceStatus(sourceId: string, status: "healthy" | "degraded" | "offline"): void;
|
|
293
|
+
/** Tell clients the stream is degraded (e.g. a feed went silent). */
|
|
294
|
+
degraded(reason: string, sourceId?: string): void;
|
|
295
|
+
stats(): LiveServerStats;
|
|
296
|
+
/** Stop timers and close every socket. */
|
|
297
|
+
stop(): void;
|
|
298
|
+
}
|
|
299
|
+
declare function createLiveServer(opts: LiveServerOptions): LiveServer;
|
|
300
|
+
|
|
301
|
+
export { type Bbox, DEFAULT_AIRBORNE_PRIORITY, DEFAULT_SURFACE_PRIORITY, type FlightPlanFilter, FlightPlanStore, type FlightPlanStoreOptions, type FusionOptions, type FusionStats, type LiveAuth, type LiveMessage, type LiveOpenContext, type LiveServer, type LiveServerOptions, type LiveServerStats, type LiveSocket, type Logger, MovementDetector, type MovementEvent, type MovementSample, type MovementType, type RemoveReason, type RemovedListener, type TrackFilter, type TrackFuser, type TrackListener, TrackState, type TrackStateOptions, type UpdateInfo, createFlightPlanStore, createLiveServer, createMovementDetector, createTrackFuser, createTrackState, defaultFusionOptions, isSyntheticId };
|