Files
kestrelos/server/utils/alpr.js
T
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

264 lines
8.2 KiB
JavaScript

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
}