bb01e9a06c
## Summary - **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles. - **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones. - **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn. ## Docs - `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys - `docs/map-and-cameras.md` — ALPR layer and map behavior updates --------- Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #36
281 lines
7.6 KiB
JavaScript
281 lines
7.6 KiB
JavaScript
/** 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
|
|
}
|