Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/** OSINT feeds (AISStream, OpenSky) → cotStore. */
|
||||
|
||||
import WebSocket from 'ws'
|
||||
import { updateFromCot } from './cotStore.js'
|
||||
import {
|
||||
broadcastSubscriberSnapshots,
|
||||
getSubscriberBboxUnion,
|
||||
} from './cotSubscribers.js'
|
||||
import { getCotSnapshotOpts } from './cotSnapshot.js'
|
||||
import { openSkyStateToCot, aisStreamMessageToCot, clampBbox } from './cotEntityUtils.js'
|
||||
import { OPENSKY_CACHE_MS, TRACKING_FEED_DEBOUNCE_MS, MAX_OPENSKY_BBOX_DEGREES } from './constants.js'
|
||||
|
||||
const COALESCE_MS = 150
|
||||
|
||||
const state = {
|
||||
aisSocket: null,
|
||||
aisReconnectTimer: null,
|
||||
aisBackoffMs: 1000,
|
||||
openSkyTimer: null,
|
||||
bboxDebounceTimer: null,
|
||||
coalesceTimer: null,
|
||||
openSkyToken: null,
|
||||
openSkyTokenExpiresAt: 0,
|
||||
/** @type {Map<string, { fetchedAt: number, states: unknown[][] }>} */
|
||||
openSkyCache: new Map(),
|
||||
lastAisBbox: null,
|
||||
stopped: false,
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return useRuntimeConfig()
|
||||
}
|
||||
|
||||
function openskyPollMs() {
|
||||
return OPENSKY_CACHE_MS
|
||||
}
|
||||
|
||||
async function fetchOpenSkyToken(clientId, clientSecret) {
|
||||
const now = Date.now()
|
||||
if (state.openSkyToken && state.openSkyTokenExpiresAt > now + 60_000) {
|
||||
return state.openSkyToken
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
const res = await fetch('https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`OpenSky auth failed: ${res.status}`)
|
||||
const json = await res.json()
|
||||
state.openSkyToken = json.access_token
|
||||
state.openSkyTokenExpiresAt = now + (Number(json.expires_in) || 1800) * 1000
|
||||
return state.openSkyToken
|
||||
}
|
||||
|
||||
function cacheKeyForBbox(bbox) {
|
||||
const round = n => Math.round(n * 10) / 10
|
||||
return `${round(bbox.west)},${round(bbox.south)},${round(bbox.east)},${round(bbox.north)}`
|
||||
}
|
||||
|
||||
async function fetchOpenSkyForBbox(bbox) {
|
||||
const config = getConfig()
|
||||
const clientId = config.openskyClientId
|
||||
const clientSecret = config.openskyClientSecret
|
||||
const cacheMs = openskyPollMs()
|
||||
const key = cacheKeyForBbox(bbox)
|
||||
const cached = state.openSkyCache.get(key)
|
||||
if (cached && Date.now() - cached.fetchedAt < cacheMs) {
|
||||
return cached.states
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lamin: String(bbox.south),
|
||||
lomin: String(bbox.west),
|
||||
lamax: String(bbox.north),
|
||||
lomax: String(bbox.east),
|
||||
})
|
||||
const headers = {}
|
||||
if (clientId && clientSecret) {
|
||||
const token = await fetchOpenSkyToken(clientId, clientSecret)
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const res = await fetch(`https://opensky-network.org/api/states/all?${params}`, { headers })
|
||||
if (!res.ok) {
|
||||
console.error('[trackingFeed] OpenSky fetch failed:', res.status)
|
||||
return []
|
||||
}
|
||||
const json = await res.json()
|
||||
const states = Array.isArray(json?.states) ? json.states : []
|
||||
state.openSkyCache.set(key, { fetchedAt: Date.now(), states })
|
||||
return states
|
||||
}
|
||||
|
||||
export function scheduleCoalescedSnapshot() {
|
||||
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
|
||||
state.coalesceTimer = setTimeout(() => {
|
||||
state.coalesceTimer = null
|
||||
broadcastSubscriberSnapshots(getCotSnapshotOpts()).catch(() => {})
|
||||
}, COALESCE_MS)
|
||||
}
|
||||
|
||||
async function ingestOpenSkyBbox(bbox) {
|
||||
try {
|
||||
const states = await fetchOpenSkyForBbox(bbox)
|
||||
for (const row of states) {
|
||||
const parsed = openSkyStateToCot(row)
|
||||
if (parsed) await updateFromCot(parsed, { silent: true })
|
||||
}
|
||||
if (states.length > 0) scheduleCoalescedSnapshot()
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[trackingFeed] OpenSky error:', err?.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOpenSky() {
|
||||
if (state.stopped) return
|
||||
const union = getSubscriberBboxUnion()
|
||||
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
|
||||
if (!bbox) return
|
||||
await ingestOpenSkyBbox(bbox)
|
||||
}
|
||||
|
||||
function stopOpenSkyPoll() {
|
||||
if (state.openSkyTimer) {
|
||||
clearInterval(state.openSkyTimer)
|
||||
state.openSkyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only. @internal */
|
||||
export function scheduleOpenSkyPollForTests() {
|
||||
scheduleOpenSkyPoll()
|
||||
}
|
||||
|
||||
function scheduleOpenSkyPoll() {
|
||||
if (state.openSkyTimer || state.stopped) return
|
||||
if (!getSubscriberBboxUnion()) return
|
||||
const intervalMs = openskyPollMs()
|
||||
state.openSkyTimer = setInterval(() => {
|
||||
pollOpenSky().catch((err) => {
|
||||
console.error('[trackingFeed] OpenSky poll error:', err?.message)
|
||||
})
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function aisBboxKey(bbox) {
|
||||
if (!bbox) return null
|
||||
return `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`
|
||||
}
|
||||
|
||||
function closeAisSocket() {
|
||||
if (state.aisSocket) {
|
||||
try {
|
||||
state.aisSocket.removeAllListeners()
|
||||
state.aisSocket.close()
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
state.aisSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAisReconnect() {
|
||||
if (state.stopped || state.aisReconnectTimer) return
|
||||
state.aisReconnectTimer = setTimeout(() => {
|
||||
state.aisReconnectTimer = null
|
||||
connectAisStream()
|
||||
}, state.aisBackoffMs)
|
||||
state.aisBackoffMs = Math.min(state.aisBackoffMs * 2, 60_000)
|
||||
}
|
||||
|
||||
function subscribeAisBbox(ws, bbox) {
|
||||
const apiKey = getConfig().aisstreamApiKey
|
||||
if (!apiKey || !bbox) return
|
||||
const key = aisBboxKey(bbox)
|
||||
if (key === state.lastAisBbox) return
|
||||
state.lastAisBbox = key
|
||||
ws.send(JSON.stringify({
|
||||
APIKey: apiKey,
|
||||
BoundingBoxes: [[[bbox.south, bbox.west], [bbox.north, bbox.east]]],
|
||||
}))
|
||||
}
|
||||
|
||||
function connectAisStream() {
|
||||
const apiKey = getConfig().aisstreamApiKey
|
||||
if (!apiKey || state.stopped) return
|
||||
closeAisSocket()
|
||||
const ws = new WebSocket('wss://stream.aisstream.io/v0/stream')
|
||||
state.aisSocket = ws
|
||||
|
||||
ws.on('open', () => {
|
||||
state.aisBackoffMs = 1000
|
||||
const union = getSubscriberBboxUnion()
|
||||
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
|
||||
if (bbox) subscribeAisBbox(ws, bbox)
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString())
|
||||
const parsed = aisStreamMessageToCot(message)
|
||||
if (parsed) updateFromCot(parsed).catch(() => {})
|
||||
}
|
||||
catch {
|
||||
/* ignore malformed AIS messages */
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
state.aisSocket = null
|
||||
if (!state.stopped) scheduleAisReconnect()
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[trackingFeed] AISStream error:', err?.message)
|
||||
})
|
||||
}
|
||||
|
||||
function refreshFeedBboxes() {
|
||||
if (state.stopped) return
|
||||
const union = getSubscriberBboxUnion()
|
||||
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
|
||||
|
||||
if (!bbox) {
|
||||
stopOpenSkyPoll()
|
||||
return
|
||||
}
|
||||
|
||||
if (getConfig().aisstreamApiKey) {
|
||||
if (state.aisSocket?.readyState === WebSocket.OPEN) {
|
||||
subscribeAisBbox(state.aisSocket, bbox)
|
||||
}
|
||||
else if (!state.aisSocket) {
|
||||
connectAisStream()
|
||||
}
|
||||
}
|
||||
|
||||
pollOpenSky().catch(() => {})
|
||||
scheduleOpenSkyPoll()
|
||||
}
|
||||
|
||||
export function scheduleTrackingFeedRefresh() {
|
||||
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
|
||||
state.bboxDebounceTimer = setTimeout(() => {
|
||||
state.bboxDebounceTimer = null
|
||||
refreshFeedBboxes()
|
||||
}, TRACKING_FEED_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
export function startTrackingFeed() {
|
||||
state.stopped = false
|
||||
}
|
||||
|
||||
export function stopTrackingFeed() {
|
||||
state.stopped = true
|
||||
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
|
||||
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
|
||||
state.coalesceTimer = null
|
||||
stopOpenSkyPoll()
|
||||
if (state.aisReconnectTimer) clearTimeout(state.aisReconnectTimer)
|
||||
state.aisReconnectTimer = null
|
||||
closeAisSocket()
|
||||
state.openSkyCache.clear()
|
||||
state.lastAisBbox = null
|
||||
}
|
||||
|
||||
/** Tests only. @internal */
|
||||
export function resetTrackingFeedForTests() {
|
||||
stopTrackingFeed()
|
||||
state.stopped = false
|
||||
}
|
||||
|
||||
/** Tests only. @internal */
|
||||
export function isOpenSkyPollActive() {
|
||||
return state.openSkyTimer != null
|
||||
}
|
||||
Reference in New Issue
Block a user