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,263 @@
|
||||
import { cameraToFeature, featureCollection } from './alprGeo.js'
|
||||
|
||||
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
|
||||
const MAX_BBOX_DEGREES = 0.5
|
||||
const CACHE_TTL_MS = 86_400_000
|
||||
const TILE_SCALE = 10
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const err = new Error(message)
|
||||
err.statusCode = statusCode
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} tags
|
||||
* @returns {Record<string, string>} String OSM tags only
|
||||
*/
|
||||
function normalizeTags(tags) {
|
||||
if (!tags || typeof tags !== 'object') return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(/** @type {Record<string, unknown>} */ (tags))
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.map(([k, v]) => [k, /** @type {string} */ (v)]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} element
|
||||
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
|
||||
*/
|
||||
export function parseOverpassElement(element) {
|
||||
if (!element || typeof element !== 'object') return null
|
||||
const el = /** @type {Record<string, unknown>} */ (element)
|
||||
if (el.type !== 'node' || typeof el.id !== 'number') return null
|
||||
const lat = Number(el.lat)
|
||||
const lng = Number(el.lon ?? el.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const tags = normalizeTags(el.tags)
|
||||
const directionRaw = tags['camera:direction'] ?? tags.direction
|
||||
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
|
||||
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
||||
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
||||
return {
|
||||
osmId: el.id,
|
||||
lat,
|
||||
lng,
|
||||
manufacturer: tags.manufacturer ?? tags.brand ?? null,
|
||||
direction: Number.isFinite(direction) ? direction : null,
|
||||
fov: Number.isFinite(fov) ? fov : null,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBbox(query) {
|
||||
const south = Number(query.south)
|
||||
const west = Number(query.west)
|
||||
const north = Number(query.north)
|
||||
const east = Number(query.east)
|
||||
if (![south, west, north, east].every(Number.isFinite)) {
|
||||
throw httpError(400, 'south, west, north, east required')
|
||||
}
|
||||
if (south >= north || west >= east) {
|
||||
throw httpError(400, 'invalid bbox')
|
||||
}
|
||||
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
|
||||
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
|
||||
}
|
||||
return { south, west, north, east }
|
||||
}
|
||||
|
||||
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
|
||||
export function tileKeysForBbox(bbox) {
|
||||
const latMin = Math.floor(bbox.south * TILE_SCALE)
|
||||
const latMax = Math.floor(bbox.north * TILE_SCALE)
|
||||
const lngMin = Math.floor(bbox.west * TILE_SCALE)
|
||||
const lngMax = Math.floor(bbox.east * TILE_SCALE)
|
||||
const keys = []
|
||||
for (let lat = latMin; lat <= latMax; lat++) {
|
||||
for (let lng = lngMin; lng <= lngMax; lng++) {
|
||||
keys.push(`${lat}:${lng}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export function buildBboxQuery(bbox) {
|
||||
const { south, west, north, east } = bbox
|
||||
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
|
||||
}
|
||||
|
||||
export function buildGlobalQuery() {
|
||||
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
|
||||
}
|
||||
|
||||
function rowToCamera(row, source) {
|
||||
const tags = (() => {
|
||||
try {
|
||||
return JSON.parse(row.tags)
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
||||
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
||||
return {
|
||||
osmId: row.osm_id,
|
||||
lat: row.lat,
|
||||
lng: row.lng,
|
||||
manufacturer: row.manufacturer ?? null,
|
||||
direction: row.direction ?? null,
|
||||
fov: Number.isFinite(fov) ? fov : null,
|
||||
tags,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function upsertAlprNode(db, camera) {
|
||||
const now = new Date().toISOString()
|
||||
await db.run(
|
||||
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(osm_id) DO UPDATE SET
|
||||
lat = excluded.lat,
|
||||
lng = excluded.lng,
|
||||
manufacturer = excluded.manufacturer,
|
||||
direction = excluded.direction,
|
||||
tags = excluded.tags,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
[
|
||||
camera.osmId,
|
||||
camera.lat,
|
||||
camera.lng,
|
||||
camera.manufacturer,
|
||||
camera.direction,
|
||||
JSON.stringify(camera.tags),
|
||||
now,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function upsertAlprNodesBatch(db, cameras) {
|
||||
if (!cameras.length) return
|
||||
await db.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
for (const camera of cameras) {
|
||||
await upsertAlprNode(db, camera)
|
||||
}
|
||||
await db.run('COMMIT')
|
||||
}
|
||||
catch (error) {
|
||||
await db.run('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function markTilesFetched(db, keys) {
|
||||
if (!keys.length) return
|
||||
const now = new Date().toISOString()
|
||||
await db.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
for (const key of keys) {
|
||||
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
|
||||
}
|
||||
await db.run('COMMIT')
|
||||
}
|
||||
catch (error) {
|
||||
await db.run('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
async function tilesAreFresh(db, keys) {
|
||||
if (!keys.length) return false
|
||||
const placeholders = keys.map(() => '?').join(',')
|
||||
const rows = await db.all(
|
||||
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
|
||||
keys,
|
||||
)
|
||||
if (rows.length !== keys.length) return false
|
||||
const cutoff = Date.now() - CACHE_TTL_MS
|
||||
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function getCachedAlprNodes(db, bbox) {
|
||||
const rows = await db.all(
|
||||
`SELECT osm_id, lat, lng, manufacturer, direction, tags
|
||||
FROM alpr_nodes
|
||||
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
|
||||
[bbox.south, bbox.north, bbox.west, bbox.east],
|
||||
)
|
||||
return rows.map(row => rowToCamera(row, 'cache'))
|
||||
}
|
||||
|
||||
export async function fetchOverpass(query) {
|
||||
const res = await fetch(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw httpError(502, `Overpass error: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const elements = Array.isArray(data?.elements) ? data.elements : []
|
||||
return elements.map(parseOverpassElement).filter(Boolean)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
async function fetchAndCacheBbox(db, bbox, tileKeys) {
|
||||
const live = await fetchOverpass(buildBboxQuery(bbox))
|
||||
await upsertAlprNodesBatch(db, live)
|
||||
await markTilesFetched(db, tileKeys)
|
||||
return live.map(c => ({ ...c, source: 'live' }))
|
||||
}
|
||||
|
||||
const refreshInFlight = new Map()
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
function refreshBboxInBackground(db, bbox, tileKeys) {
|
||||
const key = tileKeys.join('|')
|
||||
if (refreshInFlight.has(key)) return
|
||||
const job = fetchAndCacheBbox(db, bbox, tileKeys)
|
||||
.catch(() => {})
|
||||
.finally(() => refreshInFlight.delete(key))
|
||||
refreshInFlight.set(key, job)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function getAlprCameras(db, bbox) {
|
||||
const tileKeys = tileKeysForBbox(bbox)
|
||||
const cached = await getCachedAlprNodes(db, bbox)
|
||||
|
||||
if (await tilesAreFresh(db, tileKeys)) {
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
|
||||
if (cached.length > 0) {
|
||||
refreshBboxInBackground(db, bbox, tileKeys)
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
|
||||
try {
|
||||
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
|
||||
return featureCollection(live.map(cameraToFeature), 'live')
|
||||
}
|
||||
catch {
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function importAllAlprNodes(db) {
|
||||
const cameras = await fetchOverpass(buildGlobalQuery())
|
||||
await upsertAlprNodesBatch(db, cameras)
|
||||
return cameras.length
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
const ATTRIBUTION = '© OpenStreetMap contributors'
|
||||
|
||||
/** Longest match first — Flock, Genetec, Leonardo, etc. */
|
||||
const KNOWN_ALPR_MODELS = [
|
||||
'Falcon Flex',
|
||||
'AutoVu CR-H2',
|
||||
'AutoVu Sharp',
|
||||
'UnicamVELOCITY3',
|
||||
'Falcon',
|
||||
'Sparrow',
|
||||
'Raven',
|
||||
'Condor',
|
||||
'Wing',
|
||||
'Pelican',
|
||||
]
|
||||
|
||||
const GENERIC_CAMERA_NAMES = new Set([
|
||||
'flock alpr camera',
|
||||
'flock alpr cameera',
|
||||
'flock camera',
|
||||
'flock alpr',
|
||||
'flock',
|
||||
'flock camera',
|
||||
'automatic license plate reader (alpr)',
|
||||
'alpr camera',
|
||||
'alpr',
|
||||
])
|
||||
|
||||
const SKIP_EXTRA_TAGS = new Set([
|
||||
'surveillance:type',
|
||||
'man_made',
|
||||
'camera:direction',
|
||||
'direction',
|
||||
'camera:angle',
|
||||
'surveillance:angle',
|
||||
'manufacturer',
|
||||
'brand',
|
||||
'model',
|
||||
'operator',
|
||||
'name',
|
||||
'ref',
|
||||
'surveillance',
|
||||
'camera:type',
|
||||
'description',
|
||||
'note',
|
||||
'colour',
|
||||
'color',
|
||||
'manufacturer:wikidata',
|
||||
'model:wikidata',
|
||||
'operator:wikidata',
|
||||
])
|
||||
|
||||
function escapeRegex(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
export function normalizeModelName(value) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const lower = trimmed.toLowerCase()
|
||||
for (const model of KNOWN_ALPR_MODELS) {
|
||||
if (model.toLowerCase() === lower) return model
|
||||
}
|
||||
return trimmed.split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function matchKnownModel(text) {
|
||||
for (const model of KNOWN_ALPR_MODELS) {
|
||||
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
|
||||
if (re.test(text)) return model
|
||||
}
|
||||
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
|
||||
if (flock) {
|
||||
const fragment = flock[1].trim()
|
||||
if (!fragment || /^safety$/i.test(fragment)) return null
|
||||
return matchKnownModel(fragment)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function inferModelFromTags(tags) {
|
||||
const t = tags ?? {}
|
||||
if (t.model?.trim()) return normalizeModelName(t.model)
|
||||
for (const key of ['name', 'description', 'note']) {
|
||||
const value = t[key]
|
||||
if (!value?.trim()) continue
|
||||
const model = matchKnownModel(value)
|
||||
if (model) return model
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @param {string | null | undefined} name */
|
||||
export function isGenericCameraName(name) {
|
||||
if (!name?.trim()) return false
|
||||
const normalized = name.trim().toLowerCase()
|
||||
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
|
||||
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
|
||||
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function displayNameFromTags(tags) {
|
||||
const name = tags?.name?.trim()
|
||||
if (!name || isGenericCameraName(name)) return null
|
||||
if (matchKnownModel(name)) return null
|
||||
return name
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function identifyingProperties(tags, manufacturer = null) {
|
||||
const t = tags ?? {}
|
||||
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
|
||||
const model = inferModelFromTags(t)
|
||||
const name = displayNameFromTags(t)
|
||||
/** @type {Record<string, string | boolean>} */
|
||||
const props = {}
|
||||
if (mfr) props.manufacturer = mfr
|
||||
if (model) props.model = model
|
||||
if (mfr && !model) props.modelUnknown = true
|
||||
if (t.brand && t.brand !== mfr) props.brand = t.brand
|
||||
if (t.operator) props.operator = t.operator
|
||||
if (name) props.name = name
|
||||
if (t.ref) props.ref = t.ref
|
||||
if (t.surveillance) props.surveillance = t.surveillance
|
||||
if (t['camera:type']) props.cameraType = t['camera:type']
|
||||
if (t.description) props.description = t.description
|
||||
if (t.note) props.note = t.note
|
||||
const colour = t.colour ?? t.color
|
||||
if (colour) props.colour = colour
|
||||
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
|
||||
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
|
||||
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
|
||||
|
||||
const extra = {}
|
||||
for (const [key, value] of Object.entries(t)) {
|
||||
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
|
||||
extra[key] = value
|
||||
}
|
||||
if (Object.keys(extra).length) props.tags = extra
|
||||
return props
|
||||
}
|
||||
|
||||
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
|
||||
export function cameraToFeature(camera) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: camera.osmId,
|
||||
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
|
||||
properties: {
|
||||
osmId: camera.osmId,
|
||||
direction: camera.direction,
|
||||
fov: camera.fov,
|
||||
...identifyingProperties(camera.tags, camera.manufacturer),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof cameraToFeature>[]} features */
|
||||
export function featureCollection(features, source) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
attribution: ATTRIBUTION,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export { ATTRIBUTION }
|
||||
@@ -19,6 +19,8 @@ export const SKIP_PATHS = Object.freeze([
|
||||
])
|
||||
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/alpr',
|
||||
'/api/cot',
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
* Application constants with environment variable support.
|
||||
*/
|
||||
|
||||
// CoT / tracking (fixed defaults — not env-configurable)
|
||||
export const COT_TTL_MS = 90_000
|
||||
/** @deprecated Use COT_TTL_MS */
|
||||
export const COT_ENTITY_TTL_MS = COT_TTL_MS
|
||||
export const COT_OSINT_TTL_MS = 30_000
|
||||
export const COT_PRUNE_INTERVAL_MS = 15_000
|
||||
export const COT_SSE_HEARTBEAT_MS = 15_000
|
||||
export const OPENSKY_CACHE_MS = 5_000
|
||||
export const TRACKING_FEED_DEBOUNCE_MS = 500
|
||||
export const COT_TAK_FILTER_BBOX = false
|
||||
export const COT_SSE_MAX_ENTITIES = 2000
|
||||
export const MAX_OPENSKY_BBOX_DEGREES = 10
|
||||
|
||||
// Timeouts (milliseconds)
|
||||
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
|
||||
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
|
||||
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
|
||||
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
|
||||
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* CoT entity helpers: filters and OSINT → CoT mapping.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
|
||||
*/
|
||||
export function inferSourceFromId(id) {
|
||||
if (typeof id !== 'string') return 'tak'
|
||||
if (id.startsWith('ICAO.')) return 'adsb'
|
||||
if (id.startsWith('MMSI.')) return 'ais'
|
||||
return 'tak'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [type]
|
||||
* @returns {'air' | 'surface' | 'ground'} CoT display category.
|
||||
*/
|
||||
export function cotCategoryFromType(type) {
|
||||
const t = typeof type === 'string' ? type : ''
|
||||
if (t.startsWith('a-f-A-')) return 'air'
|
||||
if (t.startsWith('a-f-S-')) return 'surface'
|
||||
return 'ground'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lat: number, lng: number }} point
|
||||
* @param {{ west: number, south: number, east: number, north: number }} bbox
|
||||
*/
|
||||
export function isInBbox(point, bbox) {
|
||||
if (!point || !bbox) return false
|
||||
const { lat, lng } = point
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
|
||||
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string> | string[] | undefined} layers
|
||||
* @param {{ type?: string, source?: string }} entity
|
||||
*/
|
||||
export function matchesLayerFilter(layers, entity) {
|
||||
if (!layers || layers.size === 0) return true
|
||||
const category = cotCategoryFromType(entity.type)
|
||||
if (layers.has('air') && category === 'air') return true
|
||||
if (layers.has('surface') && category === 'surface') return true
|
||||
if (layers.has('ground') && category === 'ground') return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** OpenSky emitter category → MilStd CoT air type. */
|
||||
function openSkyCategoryToType(category) {
|
||||
if (category === 8) return 'a-f-A-C-H' // rotorcraft
|
||||
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
|
||||
return 'a-f-A-C-F'
|
||||
}
|
||||
|
||||
/** OpenSky state vector → CoT upsert. */
|
||||
export function openSkyStateToCot(state) {
|
||||
if (!Array.isArray(state) || state.length < 11) return null
|
||||
const icao24 = String(state[0] ?? '').trim().toLowerCase()
|
||||
if (!icao24) return null
|
||||
const lat = Number(state[6])
|
||||
const lng = Number(state[5])
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
|
||||
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
|
||||
const category = Number(state[17])
|
||||
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
|
||||
const heading = Number(state[10])
|
||||
const speed = Number(state[9])
|
||||
const altitude = Number(state[7])
|
||||
const verticalRate = Number(state[11])
|
||||
const onGround = state[8] === true
|
||||
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
|
||||
return {
|
||||
id: `ICAO.${icao24}`,
|
||||
lat,
|
||||
lng,
|
||||
label: callsign || icao24.toUpperCase(),
|
||||
type,
|
||||
source: 'adsb',
|
||||
icao: icao24,
|
||||
originCountry: originCountry || undefined,
|
||||
heading: Number.isFinite(heading) ? heading : undefined,
|
||||
speed: Number.isFinite(speed) ? speed : undefined,
|
||||
altitude: Number.isFinite(altitude) ? altitude : undefined,
|
||||
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
|
||||
onGround: onGround || undefined,
|
||||
squawk: squawk && squawk !== '0000' ? squawk : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** AISStream position report → CoT upsert. */
|
||||
export function aisStreamMessageToCot(message) {
|
||||
if (!message || typeof message !== 'object') return null
|
||||
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
|
||||
const msg = /** @type {Record<string, unknown>} */ (message.Message)
|
||||
if (!msg || typeof msg !== 'object') return null
|
||||
const report = /** @type {Record<string, unknown>} */ (
|
||||
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
|
||||
)
|
||||
if (!report || typeof report !== 'object') return null
|
||||
const mmsi = Number(meta?.MMSI ?? report.UserID)
|
||||
if (!Number.isFinite(mmsi)) return null
|
||||
const lat = Number(report.Latitude)
|
||||
const lng = Number(report.Longitude)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
|
||||
const heading = Number(report.Cog ?? report.TrueHeading)
|
||||
const speed = Number(report.Sog)
|
||||
return {
|
||||
id: `MMSI.${mmsi}`,
|
||||
lat,
|
||||
lng,
|
||||
label: shipName || `MMSI ${mmsi}`,
|
||||
type: 'a-f-S-C',
|
||||
source: 'ais',
|
||||
mmsi: String(mmsi),
|
||||
heading: Number.isFinite(heading) ? heading : undefined,
|
||||
speed: Number.isFinite(speed) ? speed : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of subscriber bboxes.
|
||||
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
|
||||
*/
|
||||
export function unionBboxes(boxes) {
|
||||
let west = Infinity
|
||||
let south = Infinity
|
||||
let east = -Infinity
|
||||
let north = -Infinity
|
||||
let has = false
|
||||
for (const bbox of boxes) {
|
||||
if (!bbox) continue
|
||||
has = true
|
||||
west = Math.min(west, bbox.west)
|
||||
south = Math.min(south, bbox.south)
|
||||
east = Math.max(east, bbox.east)
|
||||
north = Math.max(north, bbox.north)
|
||||
}
|
||||
return has ? { west, south, east, north } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink bbox to max span (degrees per axis), centered on midpoint.
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
|
||||
* @param {number} maxDegrees
|
||||
*/
|
||||
export function clampBbox(bbox, maxDegrees) {
|
||||
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
|
||||
const latSpan = bbox.north - bbox.south
|
||||
const lngSpan = bbox.east - bbox.west
|
||||
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
|
||||
const latMid = (bbox.north + bbox.south) / 2
|
||||
const lngMid = (bbox.east + bbox.west) / 2
|
||||
const half = maxDegrees / 2
|
||||
return {
|
||||
south: latMid - half,
|
||||
north: latMid + half,
|
||||
west: lngMid - half,
|
||||
east: lngMid + half,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} raw
|
||||
* @returns {Set<string>} Enabled layer names.
|
||||
*/
|
||||
export function parseLayersParam(raw) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return new Set(['air', 'surface', 'ground'])
|
||||
}
|
||||
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
||||
if (parts.length === 0 || parts.includes('none')) return new Set()
|
||||
return new Set(parts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} raw
|
||||
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
|
||||
*/
|
||||
export function parseBboxParam(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw.split(',').map(s => Number(s.trim()))
|
||||
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
|
||||
const [west, south, east, north] = parts
|
||||
if (south > north || west > east) return null
|
||||
return { west, south, east, north }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
COT_OSINT_TTL_MS,
|
||||
COT_SSE_MAX_ENTITIES,
|
||||
COT_TAK_FILTER_BBOX,
|
||||
COT_TTL_MS,
|
||||
} from './constants.js'
|
||||
|
||||
export function getCotSnapshotOpts() {
|
||||
return {
|
||||
ttlMs: COT_TTL_MS,
|
||||
osintTtlMs: COT_OSINT_TTL_MS,
|
||||
takFilterBbox: COT_TAK_FILTER_BBOX,
|
||||
maxEntities: COT_SSE_MAX_ENTITIES,
|
||||
}
|
||||
}
|
||||
+171
-27
@@ -1,66 +1,210 @@
|
||||
/**
|
||||
* In-memory CoT entity store: upsert by id, prune on read by TTL.
|
||||
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
|
||||
* In-memory CoT store (TAK, ADS-B, AIS).
|
||||
*/
|
||||
|
||||
import { acquire } from './asyncLock.js'
|
||||
import { COT_ENTITY_TTL_MS } from './constants.js'
|
||||
import { COT_OSINT_TTL_MS, COT_TTL_MS } from './constants.js'
|
||||
import { inferSourceFromId, isInBbox, matchesLayerFilter } from './cotEntityUtils.js'
|
||||
|
||||
const entities = new Map()
|
||||
/** @type {Set<(event: string, payload: unknown) => void>} */
|
||||
const listeners = new Set()
|
||||
|
||||
/**
|
||||
* @param {(event: string, payload: unknown) => void} fn
|
||||
* @returns {() => void} Unsubscribe function.
|
||||
*/
|
||||
export function onCotChange(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function emitChange(event, payload) {
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(event, payload)
|
||||
}
|
||||
catch {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickOptionalNumber(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
*/
|
||||
function toSnapshot(entity) {
|
||||
return {
|
||||
id: entity.id,
|
||||
lat: entity.lat,
|
||||
lng: entity.lng,
|
||||
label: entity.label ?? entity.id,
|
||||
type: entity.type ?? '',
|
||||
source: entity.source ?? 'tak',
|
||||
heading: entity.heading,
|
||||
speed: entity.speed,
|
||||
altitude: entity.altitude,
|
||||
verticalRate: entity.verticalRate,
|
||||
onGround: entity.onGround,
|
||||
originCountry: entity.originCountry,
|
||||
icao: entity.icao,
|
||||
mmsi: entity.mmsi,
|
||||
squawk: entity.squawk,
|
||||
updatedAt: entity.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
|
||||
*/
|
||||
function entityTtlMs(entity, opts) {
|
||||
const source = entity.source
|
||||
if (source === 'adsb' || source === 'ais') {
|
||||
return opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
}
|
||||
return opts.ttlMs ?? COT_TTL_MS
|
||||
}
|
||||
|
||||
function isEntityExpired(entity, now, opts) {
|
||||
return now - entity.updatedAt > entityTtlMs(entity, opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert entity by id. Input is not mutated; stored value is a new object.
|
||||
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
|
||||
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string, source?: string, heading?: number, speed?: number, altitude?: number }} parsed
|
||||
* @param {{ silent?: boolean }} [options]
|
||||
*/
|
||||
export async function updateFromCot(parsed) {
|
||||
export async function updateFromCot(parsed, options = {}) {
|
||||
if (!parsed || typeof parsed.id !== 'string') return
|
||||
const lat = Number(parsed.lat)
|
||||
const lng = Number(parsed.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
|
||||
let snapshot = null
|
||||
await acquire(`cot-${parsed.id}`, async () => {
|
||||
const now = Date.now()
|
||||
const existing = entities.get(parsed.id)
|
||||
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
|
||||
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
|
||||
const type = typeof parsed.eventType === 'string'
|
||||
? parsed.eventType
|
||||
: (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
|
||||
const explicitSource = parsed.source
|
||||
const source = explicitSource === 'adsb' || explicitSource === 'ais' || explicitSource === 'tak'
|
||||
? explicitSource
|
||||
: inferSourceFromId(parsed.id)
|
||||
|
||||
entities.set(parsed.id, {
|
||||
const stored = {
|
||||
id: parsed.id,
|
||||
lat,
|
||||
lng,
|
||||
label,
|
||||
type,
|
||||
source,
|
||||
heading: pickOptionalNumber(parsed.heading, existing?.heading),
|
||||
speed: pickOptionalNumber(parsed.speed, existing?.speed),
|
||||
altitude: pickOptionalNumber(parsed.altitude, existing?.altitude),
|
||||
verticalRate: pickOptionalNumber(parsed.verticalRate, existing?.verticalRate),
|
||||
onGround: typeof parsed.onGround === 'boolean' ? parsed.onGround : existing?.onGround,
|
||||
originCountry: typeof parsed.originCountry === 'string' ? parsed.originCountry : existing?.originCountry,
|
||||
icao: typeof parsed.icao === 'string' ? parsed.icao : existing?.icao,
|
||||
mmsi: typeof parsed.mmsi === 'string' ? parsed.mmsi : existing?.mmsi,
|
||||
squawk: typeof parsed.squawk === 'string' ? parsed.squawk : existing?.squawk,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
entities.set(parsed.id, stored)
|
||||
snapshot = toSnapshot(stored)
|
||||
})
|
||||
|
||||
if (snapshot && !options.silent) emitChange('update', { entity: snapshot })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
|
||||
*/
|
||||
function pruneExpired(now, opts) {
|
||||
const expired = []
|
||||
for (const entity of entities.values()) {
|
||||
if (isEntityExpired(entity, now, opts)) expired.push(entity.id)
|
||||
}
|
||||
for (const id of expired) {
|
||||
entities.delete(id)
|
||||
emitChange('remove', { id })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
|
||||
*/
|
||||
export async function pruneStaleEntities(opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
await acquire('cot-prune', async () => {
|
||||
pruneExpired(Date.now(), { ttlMs, osintTtlMs })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
|
||||
* @param {boolean} takFilterBbox
|
||||
*/
|
||||
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
|
||||
function passesBboxFilter(entity, bbox, takFilterBbox) {
|
||||
if (!bbox) return true
|
||||
const inBox = isInBbox(entity, bbox)
|
||||
if (entity.source === 'tak' && !takFilterBbox) return true
|
||||
return inBox
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
|
||||
*/
|
||||
export async function getActiveEntities(opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
return acquire('cot-prune', async () => {
|
||||
const now = Date.now()
|
||||
pruneExpired(now, { ttlMs, osintTtlMs })
|
||||
return [...entities.values()].map(toSnapshot)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities filtered by viewport bbox and layer set.
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number, layers?: Set<string>, takFilterBbox?: boolean, maxEntities?: number }} [opts]
|
||||
*/
|
||||
export async function getActiveEntitiesInBbox(bbox, opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
const layers = opts.layers
|
||||
const takFilterBbox = Boolean(opts.takFilterBbox)
|
||||
const ttlOpts = { ttlMs, osintTtlMs }
|
||||
|
||||
return acquire('cot-prune', async () => {
|
||||
const now = Date.now()
|
||||
pruneExpired(now, ttlOpts)
|
||||
const active = []
|
||||
const expired = []
|
||||
for (const entity of entities.values()) {
|
||||
if (now - entity.updatedAt <= ttlMs) {
|
||||
active.push({
|
||||
id: entity.id,
|
||||
lat: entity.lat,
|
||||
lng: entity.lng,
|
||||
label: entity.label ?? entity.id,
|
||||
type: entity.type ?? '',
|
||||
updatedAt: entity.updatedAt,
|
||||
})
|
||||
}
|
||||
else {
|
||||
expired.push(entity.id)
|
||||
}
|
||||
if (isEntityExpired(entity, now, ttlOpts)) continue
|
||||
const snap = toSnapshot(entity)
|
||||
if (!passesBboxFilter(snap, bbox, takFilterBbox)) continue
|
||||
if (!matchesLayerFilter(layers, snap)) continue
|
||||
active.push(snap)
|
||||
}
|
||||
const maxEntities = opts.maxEntities
|
||||
if (maxEntities != null && active.length > maxEntities) {
|
||||
active.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||
active.length = maxEntities
|
||||
}
|
||||
for (const id of expired) entities.delete(id)
|
||||
return active
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** SSE subscriber registry; bbox union drives OSINT feeds. */
|
||||
|
||||
import { getActiveEntitiesInBbox } from './cotStore.js'
|
||||
import { isInBbox, matchesLayerFilter, unionBboxes } from './cotEntityUtils.js'
|
||||
|
||||
/** @typedef {{ west: number, south: number, east: number, north: number }} Bbox */
|
||||
/** @typedef {(event: string, data: string) => Promise<void> | void} PushFn */
|
||||
|
||||
/** @type {Map<string, { bbox: Bbox | null, layers: Set<string>, push: PushFn }>} */
|
||||
const subscribers = new Map()
|
||||
let nextId = 1
|
||||
|
||||
/**
|
||||
* @param {{ bbox: Bbox | null, layers: Set<string>, push: PushFn }} sub
|
||||
* @returns {() => void} Unregister function.
|
||||
*/
|
||||
export function registerSubscriber(sub) {
|
||||
const id = String(nextId++)
|
||||
subscribers.set(id, sub)
|
||||
return () => subscribers.delete(id)
|
||||
}
|
||||
|
||||
/** @returns {Bbox | null} Union of all subscriber bboxes. */
|
||||
export function getSubscriberBboxUnion() {
|
||||
return unionBboxes([...subscribers.values()].map(s => s.bbox))
|
||||
}
|
||||
|
||||
export function getSubscriberCount() {
|
||||
return subscribers.size
|
||||
}
|
||||
|
||||
export function clearSubscribers() {
|
||||
subscribers.clear()
|
||||
}
|
||||
|
||||
export async function notifySubscribersForEntity(event, payload, entity) {
|
||||
const data = JSON.stringify(payload)
|
||||
const tasks = []
|
||||
for (const sub of subscribers.values()) {
|
||||
if (sub.bbox && !isInBbox(entity, sub.bbox)) continue
|
||||
if (!matchesLayerFilter(sub.layers, entity)) continue
|
||||
tasks.push(Promise.resolve(sub.push(event, data)))
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
export async function notifySubscribersRemove(id) {
|
||||
const data = JSON.stringify({ id })
|
||||
await Promise.all(
|
||||
[...subscribers.values()].map(sub => Promise.resolve(sub.push('remove', data))),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a filtered snapshot to each active SSE subscriber.
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number, takFilterBbox?: boolean, maxEntities?: number }} snapshotOpts
|
||||
*/
|
||||
export async function broadcastSubscriberSnapshots(snapshotOpts) {
|
||||
const tasks = []
|
||||
for (const sub of subscribers.values()) {
|
||||
tasks.push((async () => {
|
||||
const entities = await getActiveEntitiesInBbox(sub.bbox, {
|
||||
...snapshotOpts,
|
||||
layers: sub.layers,
|
||||
})
|
||||
await sub.push('snapshot', JSON.stringify({ entities }))
|
||||
})())
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
+39
-1
@@ -8,7 +8,7 @@ import { registerCleanup } from './shutdown.js'
|
||||
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
|
||||
const { DatabaseSync } = requireFromRoot('node:sqlite')
|
||||
|
||||
const SCHEMA_VERSION = 4
|
||||
const SCHEMA_VERSION = 6
|
||||
const DB_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
let dbInstance = null
|
||||
@@ -59,6 +59,20 @@ const SCHEMA = {
|
||||
source_type TEXT NOT NULL DEFAULT 'mjpeg',
|
||||
config TEXT
|
||||
)`,
|
||||
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
|
||||
osm_id INTEGER PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
manufacturer TEXT,
|
||||
direction INTEGER,
|
||||
tags TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL
|
||||
)`,
|
||||
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
|
||||
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
|
||||
tile_key TEXT PRIMARY KEY,
|
||||
fetched_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
const getDbPath = () => {
|
||||
@@ -118,6 +132,19 @@ const migrateToV4 = async (run, all) => {
|
||||
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
|
||||
}
|
||||
|
||||
const migrateToV5 = async (run, all) => {
|
||||
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
|
||||
if (tables.length > 0) return
|
||||
await run(SCHEMA.alpr_nodes)
|
||||
await run(SCHEMA.alpr_nodes_index)
|
||||
}
|
||||
|
||||
const migrateToV6 = async (run, all) => {
|
||||
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
|
||||
if (tables.length > 0) return
|
||||
await run(SCHEMA.alpr_tiles)
|
||||
}
|
||||
|
||||
const runMigrations = async (run, all, get) => {
|
||||
const version = await getSchemaVersion(get)
|
||||
if (version >= SCHEMA_VERSION) return
|
||||
@@ -133,6 +160,14 @@ const runMigrations = async (run, all, get) => {
|
||||
await migrateToV4(run, all)
|
||||
await setSchemaVersion(run, 4)
|
||||
}
|
||||
if (version < 5) {
|
||||
await migrateToV5(run, all)
|
||||
await setSchemaVersion(run, 5)
|
||||
}
|
||||
if (version < 6) {
|
||||
await migrateToV6(run, all)
|
||||
await setSchemaVersion(run, 6)
|
||||
}
|
||||
}
|
||||
|
||||
const initDb = async (db, run, all, get) => {
|
||||
@@ -149,6 +184,9 @@ const initDb = async (db, run, all, get) => {
|
||||
await run(SCHEMA.sessions)
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
await run(SCHEMA.alpr_nodes)
|
||||
await run(SCHEMA.alpr_nodes_index)
|
||||
await run(SCHEMA.alpr_tiles)
|
||||
|
||||
if (!testPath) {
|
||||
// Bootstrap admin user on first run
|
||||
|
||||
@@ -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