Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
PR / lint (pull_request) Failing after 31s
PR / test (pull_request) Successful in 45s
PR / docker-build (pull_request) Successful in 1m3s
PR / e2e (pull_request) Successful in 1m33s

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:
Madison Grubb
2026-06-24 16:24:41 -04:00
parent a6b87305a1
commit aa8a0bd83f
64 changed files with 5761 additions and 2119 deletions
+263
View File
@@ -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
}
+175
View File
@@ -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 }
+2
View File
@@ -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',
+13 -1
View File
@@ -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
+191
View File
@@ -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 }
}
+15
View File
@@ -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
View File
@@ -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
})
}
+70
View File
@@ -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
View File
@@ -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
+280
View File
@@ -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
}