Files
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## 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
2026-06-24 20:54:50 +00:00

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
}