feat: lean-Pi WS fan-out, geofences, DVR playback, fire/aircraft correlation
Phase 1: in-memory ConnectionManager viewport fan-out, 500ms map debounce, cachetools TTLCache, background masscan/ffmpeg, compose memory caps. Phase 2: PostGIS geofences + ST_Intersects alerts, Timescale 1-min CAGGs and timestamp playback, FIRMS/WFIGS x firefighting ADS-B within 20 miles. No Redis/Kafka/Celery.
This commit is contained in:
parent
8978324d33
commit
5ea9a4e879
28 changed files with 2318 additions and 50 deletions
196
alembic/versions/005_phase2.py
Normal file
196
alembic/versions/005_phase2.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""phase 2: geofences, 1-min track CAGGs, fire/aircraft hits
|
||||||
|
|
||||||
|
Revision ID: 005_phase2
|
||||||
|
Revises: 004_camera_enum
|
||||||
|
Create Date: 2026-08-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "005_phase2"
|
||||||
|
down_revision = "004_camera_enum"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS geofences (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
geojson JSONB NOT NULL,
|
||||||
|
geom geometry(Polygon, 4326),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_geofences_geom
|
||||||
|
ON geofences USING gist (geom)
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS geofence_alerts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
geofence_id UUID NOT NULL,
|
||||||
|
source_kind TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lon DOUBLE PRECISION,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_geofence_alerts_created
|
||||||
|
ON geofence_alerts (created_at DESC)
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS vessel_positions (
|
||||||
|
mmsi TEXT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
lat DOUBLE PRECISION NOT NULL,
|
||||||
|
lon DOUBLE PRECISION NOT NULL,
|
||||||
|
heading DOUBLE PRECISION,
|
||||||
|
speed DOUBLE PRECISION,
|
||||||
|
label TEXT,
|
||||||
|
extra JSONB,
|
||||||
|
PRIMARY KEY (mmsi, ts)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
SELECT create_hypertable(
|
||||||
|
'vessel_positions', 'ts', if_not_exists => TRUE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_vessel_positions_bbox
|
||||||
|
ON vessel_positions (lon, lat)
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS aircraft_positions (
|
||||||
|
hex TEXT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
lat DOUBLE PRECISION NOT NULL,
|
||||||
|
lon DOUBLE PRECISION NOT NULL,
|
||||||
|
heading DOUBLE PRECISION,
|
||||||
|
speed DOUBLE PRECISION,
|
||||||
|
label TEXT,
|
||||||
|
extra JSONB,
|
||||||
|
PRIMARY KEY (hex, ts)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
SELECT create_hypertable(
|
||||||
|
'aircraft_positions', 'ts', if_not_exists => TRUE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_aircraft_positions_bbox
|
||||||
|
ON aircraft_positions (lon, lat)
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS fire_aircraft_hits (
|
||||||
|
id UUID NOT NULL,
|
||||||
|
fire_id TEXT NOT NULL,
|
||||||
|
fire_lat DOUBLE PRECISION NOT NULL,
|
||||||
|
fire_lon DOUBLE PRECISION NOT NULL,
|
||||||
|
aircraft_hex TEXT NOT NULL,
|
||||||
|
aircraft_type TEXT,
|
||||||
|
aircraft_lat DOUBLE PRECISION NOT NULL,
|
||||||
|
aircraft_lon DOUBLE PRECISION NOT NULL,
|
||||||
|
distance_mi DOUBLE PRECISION NOT NULL,
|
||||||
|
seen_at TIMESTAMPTZ NOT NULL,
|
||||||
|
PRIMARY KEY (fire_id, aircraft_hex, seen_at)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_fire_aircraft_hits_seen
|
||||||
|
ON fire_aircraft_hits (seen_at DESC)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 1-minute continuous aggregates (Timescale). last() keeps the newest
|
||||||
|
# sample in each bucket — the DVR slider reads these, not the raw table.
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE view_name = 'vessel_tracks_1min'
|
||||||
|
) THEN
|
||||||
|
EXECUTE $v$
|
||||||
|
CREATE MATERIALIZED VIEW vessel_tracks_1min
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 minute', ts) AS bucket,
|
||||||
|
mmsi,
|
||||||
|
last(lat, ts) AS lat,
|
||||||
|
last(lon, ts) AS lon,
|
||||||
|
last(heading, ts) AS heading,
|
||||||
|
last(speed, ts) AS speed,
|
||||||
|
last(label, ts) AS label
|
||||||
|
FROM vessel_positions
|
||||||
|
GROUP BY bucket, mmsi
|
||||||
|
WITH NO DATA
|
||||||
|
$v$;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE view_name = 'aircraft_tracks_1min'
|
||||||
|
) THEN
|
||||||
|
EXECUTE $a$
|
||||||
|
CREATE MATERIALIZED VIEW aircraft_tracks_1min
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 minute', ts) AS bucket,
|
||||||
|
hex,
|
||||||
|
last(lat, ts) AS lat,
|
||||||
|
last(lon, ts) AS lon,
|
||||||
|
last(heading, ts) AS heading,
|
||||||
|
last(speed, ts) AS speed,
|
||||||
|
last(label, ts) AS label
|
||||||
|
FROM aircraft_positions
|
||||||
|
GROUP BY bucket, hex
|
||||||
|
WITH NO DATA
|
||||||
|
$a$;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM add_continuous_aggregate_policy(
|
||||||
|
'vessel_tracks_1min',
|
||||||
|
start_offset => INTERVAL '3 hours',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
PERFORM add_continuous_aggregate_policy(
|
||||||
|
'aircraft_tracks_1min',
|
||||||
|
start_offset => INTERVAL '3 hours',
|
||||||
|
end_offset => INTERVAL '1 minute',
|
||||||
|
schedule_interval => INTERVAL '1 minute',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP MATERIALIZED VIEW IF EXISTS aircraft_tracks_1min CASCADE")
|
||||||
|
op.execute("DROP MATERIALIZED VIEW IF EXISTS vessel_tracks_1min CASCADE")
|
||||||
|
op.execute("DROP TABLE IF EXISTS fire_aircraft_hits")
|
||||||
|
op.execute("DROP TABLE IF EXISTS aircraft_positions")
|
||||||
|
op.execute("DROP TABLE IF EXISTS vessel_positions")
|
||||||
|
op.execute("DROP TABLE IF EXISTS geofence_alerts")
|
||||||
|
op.execute("DROP TABLE IF EXISTS geofences")
|
||||||
103
app/bg_jobs.py
Normal file
103
app/bg_jobs.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Background masscan / ffmpeg — never block a FastAPI request on a scan.
|
||||||
|
|
||||||
|
masscan is capped at 200 pps (home uplink saturates at 1k+). ffmpeg frame
|
||||||
|
grabs are scheduled with asyncio.create_task and shared per URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.bg_jobs")
|
||||||
|
|
||||||
|
MASSCAN_PPS_CAP = 200
|
||||||
|
|
||||||
|
_masscan_task: asyncio.Task | None = None
|
||||||
|
_ffmpeg_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
||||||
|
_ffmpeg_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
_FFMPEG = shutil.which("ffmpeg")
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_masscan_pass() -> bool:
|
||||||
|
"""Kick one capped masscan pass. Returns False if a pass is already running."""
|
||||||
|
global _masscan_task
|
||||||
|
if _masscan_task is not None and not _masscan_task.done():
|
||||||
|
return False
|
||||||
|
_masscan_task = asyncio.create_task(_run_masscan_capped())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_masscan_capped() -> None:
|
||||||
|
import masscan_config as cfg
|
||||||
|
from run_masscan_service import _verify_excludefile, run_pass
|
||||||
|
|
||||||
|
orig = cfg.MASSCAN_RATE
|
||||||
|
if orig > MASSCAN_PPS_CAP:
|
||||||
|
logger.warning("capping masscan rate %s pps -> %s", orig, MASSCAN_PPS_CAP)
|
||||||
|
cfg.MASSCAN_RATE = MASSCAN_PPS_CAP
|
||||||
|
try:
|
||||||
|
_verify_excludefile()
|
||||||
|
await run_pass()
|
||||||
|
finally:
|
||||||
|
cfg.MASSCAN_RATE = orig
|
||||||
|
|
||||||
|
|
||||||
|
def cached_ffmpeg_jpeg(url: str) -> bytes | None:
|
||||||
|
return _ffmpeg_cache.get(url)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_ffmpeg_snapshot(url: str, timeout: float = 8.0) -> asyncio.Task:
|
||||||
|
"""Start (or reuse) an ffmpeg JPEG grab. Caller may await the task."""
|
||||||
|
existing = _ffmpeg_tasks.get(url)
|
||||||
|
if existing is not None and not existing.done():
|
||||||
|
return existing
|
||||||
|
task = asyncio.create_task(_ffmpeg_grab_and_cache(url, timeout))
|
||||||
|
_ffmpeg_tasks[url] = task
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _ffmpeg_grab(url: str, timeout: float = 8.0) -> bytes | None:
|
||||||
|
"""Grab one JPEG frame. Isolated so tests can stub it."""
|
||||||
|
if not _FFMPEG or not url.lower().startswith("rtsp://"):
|
||||||
|
return None
|
||||||
|
cmd = [
|
||||||
|
_FFMPEG, "-hide_banner", "-loglevel", "error", "-nostdin",
|
||||||
|
"-rtsp_transport", "tcp",
|
||||||
|
"-timeout", "4000000",
|
||||||
|
"-i", url,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-f", "image2pipe", "-vcodec", "mjpeg",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
await proc.wait()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
if proc.returncode not in (0, None) or not stdout or len(stdout) < 64:
|
||||||
|
return None
|
||||||
|
if stdout[:2] != b"\xff\xd8":
|
||||||
|
return None
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
async def _ffmpeg_grab_and_cache(url: str, timeout: float) -> bytes | None:
|
||||||
|
data = await _ffmpeg_grab(url, timeout)
|
||||||
|
if data:
|
||||||
|
_ffmpeg_cache[url] = data
|
||||||
|
return data
|
||||||
|
|
@ -137,38 +137,17 @@ async def probe_public_feed(host: str) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
async def ffmpeg_snapshot(url: str, timeout: float = 8.0) -> bytes | None:
|
async def ffmpeg_snapshot(url: str, timeout: float = 8.0) -> bytes | None:
|
||||||
"""Grab a single JPEG frame from an RTSP URL. None if ffmpeg missing/fails."""
|
"""Grab a single JPEG frame from an RTSP URL. None if ffmpeg missing/fails.
|
||||||
if not _FFMPEG or not url.lower().startswith("rtsp://"):
|
|
||||||
return None
|
The subprocess is scheduled via asyncio.create_task (shared per URL) so
|
||||||
cmd = [
|
concurrent popup clicks do not stack ffmpeg processes on the request path.
|
||||||
_FFMPEG, "-hide_banner", "-loglevel", "error", "-nostdin",
|
"""
|
||||||
"-rtsp_transport", "tcp",
|
from bg_jobs import cached_ffmpeg_jpeg, schedule_ffmpeg_snapshot
|
||||||
"-timeout", "4000000", # 4s socket timeout, microseconds
|
|
||||||
"-i", url,
|
hit = cached_ffmpeg_jpeg(url)
|
||||||
"-frames:v", "1",
|
if hit:
|
||||||
"-f", "image2pipe", "-vcodec", "mjpeg",
|
return hit
|
||||||
"pipe:1",
|
return await schedule_ffmpeg_snapshot(url, timeout)
|
||||||
]
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
try:
|
|
||||||
await proc.wait()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
if proc.returncode not in (0, None) or not _looks_like_jpeg(stdout or b""):
|
|
||||||
return None
|
|
||||||
return stdout
|
|
||||||
|
|
||||||
|
|
||||||
async def ffmpeg_mjpeg_stream(url: str):
|
async def ffmpeg_mjpeg_stream(url: str):
|
||||||
|
|
|
||||||
186
app/fire_aircraft.py
Normal file
186
app/fire_aircraft.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Flag firefighting ADS-B aircraft within 20 miles of an active fire."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import async_session
|
||||||
|
from live_layers import _haversine_km
|
||||||
|
|
||||||
|
|
||||||
|
RADIUS_MILES = 20.0
|
||||||
|
RADIUS_KM = RADIUS_MILES * 1.609344
|
||||||
|
_recent: dict[tuple[str, str], datetime] = {}
|
||||||
|
_COOLDOWN = timedelta(minutes=5)
|
||||||
|
|
||||||
|
# ICAO type designators commonly used on wildfire air tankers, scoopers,
|
||||||
|
# helitack, and air-attack platforms. Uppercase; compared case-insensitively.
|
||||||
|
FIREFIGHTER_ICAO = frozenset({
|
||||||
|
"AT802", "AT8T", "AT8P", "AT8B",
|
||||||
|
"C130", "C30J", "C130J",
|
||||||
|
"DC10", "MD10", "MD11", "MD87",
|
||||||
|
"B737", "B38M",
|
||||||
|
"CL2T", "CL215", "CL415", "CL5T",
|
||||||
|
"S64", "SK64",
|
||||||
|
"UH1", "UH1Y", "UH60", "H60", "S70",
|
||||||
|
"B412", "B212", "B205",
|
||||||
|
"AS50", "AS350", "A119", "A109",
|
||||||
|
"B350", "BE20",
|
||||||
|
"OV10",
|
||||||
|
"RJ85", "RJ1H", "B461", "B462", "B463",
|
||||||
|
"PC12",
|
||||||
|
"TBM7", "TBM8", "TBM9",
|
||||||
|
"C208",
|
||||||
|
"DH8D", "Q400",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _icao(marker: dict) -> str:
|
||||||
|
extra = marker.get("extra") or {}
|
||||||
|
return str(extra.get("type") or extra.get("t") or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def is_firefighter(marker: dict) -> bool:
|
||||||
|
return _icao(marker) in FIREFIGHTER_ICAO
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_mi(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
return _haversine_km(lat1, lon1, lat2, lon2) / 1.609344
|
||||||
|
|
||||||
|
|
||||||
|
def correlate_aircraft_to_fires(
|
||||||
|
fires: list[dict],
|
||||||
|
aircraft: list[dict],
|
||||||
|
radius_mi: float = RADIUS_MILES,
|
||||||
|
) -> list[dict]:
|
||||||
|
hits: list[dict] = []
|
||||||
|
for fire in fires:
|
||||||
|
flat, flon = fire.get("lat"), fire.get("lon")
|
||||||
|
if flat is None or flon is None:
|
||||||
|
continue
|
||||||
|
fid = str(fire.get("id") or fire.get("label") or "fire")
|
||||||
|
for ac in aircraft:
|
||||||
|
if not is_firefighter(ac):
|
||||||
|
continue
|
||||||
|
alat, alon = ac.get("lat"), ac.get("lon")
|
||||||
|
if alat is None or alon is None:
|
||||||
|
continue
|
||||||
|
dist = _haversine_mi(float(flat), float(flon), float(alat), float(alon))
|
||||||
|
if dist > radius_mi:
|
||||||
|
continue
|
||||||
|
hits.append({
|
||||||
|
"fire_id": fid,
|
||||||
|
"fire_lat": float(flat),
|
||||||
|
"fire_lon": float(flon),
|
||||||
|
"aircraft_hex": str(ac.get("id")),
|
||||||
|
"aircraft_type": _icao(ac),
|
||||||
|
"aircraft_lat": float(alat),
|
||||||
|
"aircraft_lon": float(alon),
|
||||||
|
"distance_mi": round(dist, 2),
|
||||||
|
"label": ac.get("label") or ac.get("id"),
|
||||||
|
})
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_hits(hits: list[dict], seen_at: datetime | None = None) -> int:
|
||||||
|
if not hits:
|
||||||
|
return 0
|
||||||
|
ts = seen_at or datetime.now(timezone.utc)
|
||||||
|
n = 0
|
||||||
|
async with async_session() as session:
|
||||||
|
for h in hits:
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO fire_aircraft_hits
|
||||||
|
(id, fire_id, fire_lat, fire_lon,
|
||||||
|
aircraft_hex, aircraft_type, aircraft_lat, aircraft_lon,
|
||||||
|
distance_mi, seen_at)
|
||||||
|
VALUES (
|
||||||
|
CAST(:id AS uuid), :fire_id, :fire_lat, :fire_lon,
|
||||||
|
:aircraft_hex, :aircraft_type, :aircraft_lat, :aircraft_lon,
|
||||||
|
:distance_mi, :seen_at
|
||||||
|
)
|
||||||
|
ON CONFLICT (fire_id, aircraft_hex, seen_at) DO NOTHING
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"fire_id": h["fire_id"],
|
||||||
|
"fire_lat": h["fire_lat"],
|
||||||
|
"fire_lon": h["fire_lon"],
|
||||||
|
"aircraft_hex": h["aircraft_hex"],
|
||||||
|
"aircraft_type": h["aircraft_type"],
|
||||||
|
"aircraft_lat": h["aircraft_lat"],
|
||||||
|
"aircraft_lon": h["aircraft_lon"],
|
||||||
|
"distance_mi": h["distance_mi"],
|
||||||
|
"seen_at": ts.replace(microsecond=0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def recent_hits(limit: int = 200) -> list[dict]:
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT fire_id, fire_lat, fire_lon,
|
||||||
|
aircraft_hex, aircraft_type, aircraft_lat, aircraft_lon,
|
||||||
|
distance_mi, seen_at
|
||||||
|
FROM fire_aircraft_hits
|
||||||
|
ORDER BY seen_at DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"limit": limit},
|
||||||
|
)).mappings().all()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
item = dict(r)
|
||||||
|
if item.get("seen_at") is not None:
|
||||||
|
item["seen_at"] = item["seen_at"].isoformat()
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def correlate_and_notify(fires: list[dict], aircraft: list[dict]) -> list[dict]:
|
||||||
|
hits = correlate_aircraft_to_fires(fires, aircraft)
|
||||||
|
if not hits:
|
||||||
|
return []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
fresh = []
|
||||||
|
for h in hits:
|
||||||
|
key = (h["fire_id"], h["aircraft_hex"])
|
||||||
|
prev = _recent.get(key)
|
||||||
|
if prev is not None and now - prev < _COOLDOWN:
|
||||||
|
continue
|
||||||
|
_recent[key] = now
|
||||||
|
fresh.append(h)
|
||||||
|
if not fresh:
|
||||||
|
return []
|
||||||
|
await persist_hits(fresh, seen_at=now)
|
||||||
|
from ws_manager import manager
|
||||||
|
for h in fresh:
|
||||||
|
body = {**h, "seen_at": now.isoformat()}
|
||||||
|
await manager.publish_point(
|
||||||
|
"fire_aircraft", body, lat=h["aircraft_lat"], lon=h["aircraft_lon"],
|
||||||
|
)
|
||||||
|
await manager.publish_point(
|
||||||
|
"fire_aircraft", body, lat=h["fire_lat"], lon=h["fire_lon"],
|
||||||
|
)
|
||||||
|
return fresh
|
||||||
|
|
@ -40,6 +40,7 @@ from config import (
|
||||||
NATS_URL,
|
NATS_URL,
|
||||||
)
|
)
|
||||||
from keystore import get_api_key
|
from keystore import get_api_key
|
||||||
|
from upstream_cache import firms_cache
|
||||||
|
|
||||||
logger = logging.getLogger("osint.firms")
|
logger = logging.getLogger("osint.firms")
|
||||||
|
|
||||||
|
|
@ -185,12 +186,16 @@ async def ingest_fires(bbox: str | None = None) -> int:
|
||||||
total_published = 0
|
total_published = 0
|
||||||
async with httpx.AsyncClient(timeout=FIRMS_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=FIRMS_TIMEOUT) as client:
|
||||||
for dataset in datasets:
|
for dataset in datasets:
|
||||||
|
cache_key = (dataset, area, FIRMS_DAYS)
|
||||||
|
text = firms_cache.get(cache_key)
|
||||||
|
if text is None:
|
||||||
url = FIRMS_AREA_CSV.format(
|
url = FIRMS_AREA_CSV.format(
|
||||||
key=map_key, dataset=dataset, bbox=area, days=FIRMS_DAYS
|
key=map_key, dataset=dataset, bbox=area, days=FIRMS_DAYS
|
||||||
)
|
)
|
||||||
resp = await client.get(url)
|
resp = await client.get(url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
text = resp.text
|
text = resp.text
|
||||||
|
firms_cache[cache_key] = text
|
||||||
# FIRMS returns HTTP 200 with a plain-text error for some failure modes
|
# FIRMS returns HTTP 200 with a plain-text error for some failure modes
|
||||||
# (bad key, invalid bbox); surface the first line for debuggability.
|
# (bad key, invalid bbox); surface the first line for debuggability.
|
||||||
if "latitude" not in text.lower()[:4096]:
|
if "latitude" not in text.lower()[:4096]:
|
||||||
|
|
|
||||||
321
app/geofence.py
Normal file
321
app/geofence.py
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
"""Geofences: GeoJSON polygons, ST_Intersects on ingest, WS alerts.
|
||||||
|
|
||||||
|
``/api/alerts`` is the dashboard entity/keyword table — geofence hits live
|
||||||
|
in ``geofence_alerts`` and fan out as WS type ``geofence_alert``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import async_session
|
||||||
|
|
||||||
|
|
||||||
|
def _rings_from_geojson(geojson: dict) -> list[list[list[float]]]:
|
||||||
|
if not isinstance(geojson, dict):
|
||||||
|
raise ValueError("geojson must be an object")
|
||||||
|
gj = geojson
|
||||||
|
if gj.get("type") == "Feature":
|
||||||
|
gj = gj.get("geometry") or {}
|
||||||
|
if gj.get("type") == "FeatureCollection":
|
||||||
|
raise ValueError("FeatureCollection is not a single polygon")
|
||||||
|
if gj.get("type") != "Polygon":
|
||||||
|
raise ValueError("geojson must be a Polygon")
|
||||||
|
coords = gj.get("coordinates")
|
||||||
|
if not isinstance(coords, list) or not coords:
|
||||||
|
raise ValueError("polygon has no rings")
|
||||||
|
rings: list[list[list[float]]] = []
|
||||||
|
for ring in coords:
|
||||||
|
if not isinstance(ring, list) or len(ring) < 4:
|
||||||
|
raise ValueError("polygon ring needs ≥4 positions (closed)")
|
||||||
|
pts = []
|
||||||
|
for pt in ring:
|
||||||
|
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
||||||
|
raise ValueError("position must be [lon, lat]")
|
||||||
|
pts.append([float(pt[0]), float(pt[1])])
|
||||||
|
rings.append(pts)
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
|
def validate_polygon_geojson(geojson: dict) -> dict:
|
||||||
|
"""Return a canonical Polygon GeoJSON or raise ValueError."""
|
||||||
|
rings = _rings_from_geojson(geojson)
|
||||||
|
return {"type": "Polygon", "coordinates": rings}
|
||||||
|
|
||||||
|
|
||||||
|
def _ring_contains(lon: float, lat: float, ring: list[list[float]]) -> bool:
|
||||||
|
"""Ray-cast even-odd rule. Ring is [lon, lat] positions."""
|
||||||
|
inside = False
|
||||||
|
n = len(ring)
|
||||||
|
if n < 4:
|
||||||
|
return False
|
||||||
|
j = n - 1
|
||||||
|
for i in range(n):
|
||||||
|
xi, yi = ring[i][0], ring[i][1]
|
||||||
|
xj, yj = ring[j][0], ring[j][1]
|
||||||
|
intersects = ((yi > lat) != (yj > lat)) and (
|
||||||
|
lon < (xj - xi) * (lat - yi) / ((yj - yi) or 1e-16) + xi
|
||||||
|
)
|
||||||
|
if intersects:
|
||||||
|
inside = not inside
|
||||||
|
j = i
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_geojson(lon: float, lat: float, geojson: dict) -> bool:
|
||||||
|
"""True if (lon, lat) is inside the outer ring and outside holes."""
|
||||||
|
try:
|
||||||
|
rings = _rings_from_geojson(geojson)
|
||||||
|
except (ValueError, TypeError, KeyError):
|
||||||
|
return False
|
||||||
|
if not _ring_contains(lon, lat, rings[0]):
|
||||||
|
return False
|
||||||
|
for hole in rings[1:]:
|
||||||
|
if _ring_contains(lon, lat, hole):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def matching_geofences(lon: float, lat: float, fences: list[dict]) -> list[dict]:
|
||||||
|
hits = []
|
||||||
|
for fence in fences:
|
||||||
|
if not fence.get("active", True):
|
||||||
|
continue
|
||||||
|
gj = fence.get("geojson") or {}
|
||||||
|
if point_in_geojson(lon, lat, gj):
|
||||||
|
hits.append(fence)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
# In-process copy of active fences so ingest does not round-trip Postgres
|
||||||
|
# on every AIS frame. CRUD endpoints refresh this list.
|
||||||
|
_cache: list[dict] = []
|
||||||
|
_recent_hits: dict[tuple[str, str], datetime] = {}
|
||||||
|
_HIT_COOLDOWN = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_cache() -> list[dict]:
|
||||||
|
global _cache
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(text(
|
||||||
|
"SELECT id::text, name, geojson, active FROM geofences"
|
||||||
|
))).mappings().all()
|
||||||
|
_cache = [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"geojson": r["geojson"] if isinstance(r["geojson"], dict)
|
||||||
|
else json.loads(r["geojson"] or "{}"),
|
||||||
|
"active": bool(r["active"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def cached_fences() -> list[dict]:
|
||||||
|
return list(_cache)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_geofences() -> list[dict]:
|
||||||
|
if not _cache:
|
||||||
|
try:
|
||||||
|
await refresh_cache()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return cached_fences()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_geofence(name: str, geojson: dict, active: bool = True) -> dict:
|
||||||
|
polygon = validate_polygon_geojson(geojson)
|
||||||
|
gid = str(uuid4())
|
||||||
|
gj = json.dumps(polygon)
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO geofences (id, name, geojson, geom, active)
|
||||||
|
VALUES (
|
||||||
|
:id, :name, CAST(:geojson AS jsonb),
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON(:geojson), 4326),
|
||||||
|
:active
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": gid, "name": name, "geojson": gj, "active": 1 if active else 0},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
row = {"id": gid, "name": name, "geojson": polygon, "active": active}
|
||||||
|
_cache.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def update_geofence(gid: str, *, name: str | None = None,
|
||||||
|
geojson: dict | None = None,
|
||||||
|
active: bool | None = None) -> dict | None:
|
||||||
|
current = next((f for f in _cache if f["id"] == gid), None)
|
||||||
|
if current is None:
|
||||||
|
await refresh_cache()
|
||||||
|
current = next((f for f in _cache if f["id"] == gid), None)
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
if name is not None:
|
||||||
|
current["name"] = name
|
||||||
|
if geojson is not None:
|
||||||
|
current["geojson"] = validate_polygon_geojson(geojson)
|
||||||
|
if active is not None:
|
||||||
|
current["active"] = active
|
||||||
|
gj = json.dumps(current["geojson"])
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE geofences SET
|
||||||
|
name = :name,
|
||||||
|
geojson = CAST(:geojson AS jsonb),
|
||||||
|
geom = ST_SetSRID(ST_GeomFromGeoJSON(:geojson), 4326),
|
||||||
|
active = :active,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = CAST(:id AS uuid)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": gid,
|
||||||
|
"name": current["name"],
|
||||||
|
"geojson": gj,
|
||||||
|
"active": 1 if current["active"] else 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_geofence(gid: str) -> bool:
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
text("DELETE FROM geofences WHERE id = CAST(:id AS uuid)"),
|
||||||
|
{"id": gid},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
_cache[:] = [f for f in _cache if f["id"] != gid]
|
||||||
|
return bool(result.rowcount)
|
||||||
|
|
||||||
|
|
||||||
|
async def st_intersects(lon: float, lat: float) -> list[dict]:
|
||||||
|
"""PostGIS ST_Intersects against active geofences.
|
||||||
|
|
||||||
|
Falls back to the in-memory GeoJSON test if the DB is unreachable so
|
||||||
|
ingest never dies because a fence check failed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id::text, name, geojson, active
|
||||||
|
FROM geofences
|
||||||
|
WHERE active = 1
|
||||||
|
AND ST_Intersects(
|
||||||
|
geom,
|
||||||
|
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"lon": lon, "lat": lat},
|
||||||
|
)).mappings().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"geojson": r["geojson"] if isinstance(r["geojson"], dict)
|
||||||
|
else json.loads(r["geojson"] or "{}"),
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return matching_geofences(lon, lat, cached_fences())
|
||||||
|
|
||||||
|
|
||||||
|
async def record_and_notify(
|
||||||
|
*,
|
||||||
|
source_kind: str,
|
||||||
|
entity_id: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a geofence_alerts row per hit and WS-push to viewport clients.
|
||||||
|
|
||||||
|
PostGIS ST_Intersects is the source of truth. The in-process GeoJSON
|
||||||
|
cache is not a reject filter — the FIRMS ingester never fills it.
|
||||||
|
"""
|
||||||
|
hits = await st_intersects(lon, lat)
|
||||||
|
if not hits:
|
||||||
|
return 0
|
||||||
|
from ws_manager import manager
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
fresh = []
|
||||||
|
for fence in hits:
|
||||||
|
key = (str(fence["id"]), str(entity_id))
|
||||||
|
prev = _recent_hits.get(key)
|
||||||
|
if prev is not None and now - prev < _HIT_COOLDOWN:
|
||||||
|
continue
|
||||||
|
_recent_hits[key] = now
|
||||||
|
fresh.append(fence)
|
||||||
|
if not fresh:
|
||||||
|
return 0
|
||||||
|
hits = fresh
|
||||||
|
async with async_session() as session:
|
||||||
|
for fence in hits:
|
||||||
|
aid = str(uuid4())
|
||||||
|
body = {
|
||||||
|
"id": aid,
|
||||||
|
"geofence_id": fence["id"],
|
||||||
|
"geofence_name": fence.get("name"),
|
||||||
|
"source_kind": source_kind,
|
||||||
|
"entity_id": str(entity_id),
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"payload": payload or {},
|
||||||
|
"created_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO geofence_alerts
|
||||||
|
(id, geofence_id, source_kind, entity_id, lat, lon, payload)
|
||||||
|
VALUES (
|
||||||
|
CAST(:id AS uuid), CAST(:geofence_id AS uuid),
|
||||||
|
:source_kind, :entity_id, :lat, :lon, CAST(:payload AS jsonb)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": aid,
|
||||||
|
"geofence_id": fence["id"],
|
||||||
|
"source_kind": source_kind,
|
||||||
|
"entity_id": str(entity_id),
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"payload": json.dumps(payload or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sent += await manager.publish_point(
|
||||||
|
"geofence_alert", body, lat=lat, lon=lon,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sent
|
||||||
|
|
@ -95,6 +95,23 @@ async def ingest_fire_row(msg: dict) -> bool:
|
||||||
"ingested fire %.5f,%.5f %s satellite=%s",
|
"ingested fire %.5f,%.5f %s satellite=%s",
|
||||||
row["latitude"], row["longitude"], row["acq_time"], row["satellite"],
|
row["latitude"], row["longitude"], row["acq_time"], row["satellite"],
|
||||||
)
|
)
|
||||||
|
lat, lon = row["latitude"], row["longitude"]
|
||||||
|
from geofence import record_and_notify
|
||||||
|
await record_and_notify(
|
||||||
|
source_kind="firms",
|
||||||
|
entity_id=f"{lat},{lon},{row['satellite']}",
|
||||||
|
lat=lat, lon=lon, payload={"satellite": row["satellite"]},
|
||||||
|
)
|
||||||
|
from live_layers import aircraft_last_known
|
||||||
|
from fire_aircraft import correlate_and_notify
|
||||||
|
from tracks import recent_markers
|
||||||
|
acs = list(aircraft_last_known.values()) or await recent_markers("aircraft")
|
||||||
|
if acs:
|
||||||
|
fire = {
|
||||||
|
"id": f"firms:{lat:.4f},{lon:.4f}",
|
||||||
|
"lat": lat, "lon": lon, "label": "FIRMS",
|
||||||
|
}
|
||||||
|
await correlate_and_notify([fire], acs)
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ vessel_lock = asyncio.Lock()
|
||||||
# Viewport-following accumulates vessels across every region visited in a
|
# Viewport-following accumulates vessels across every region visited in a
|
||||||
# session — keep the in-memory store bounded (oldest entries evicted).
|
# session — keep the in-memory store bounded (oldest entries evicted).
|
||||||
_MAX_VESSELS = 6000
|
_MAX_VESSELS = 6000
|
||||||
|
# Last ADS-B snapshot + WFIGS points for fire↔tanker correlation.
|
||||||
|
aircraft_last_known: dict[str, dict] = {}
|
||||||
|
fire_last_known: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
def overlay_catalog() -> dict:
|
def overlay_catalog() -> dict:
|
||||||
|
|
@ -576,6 +579,25 @@ async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
||||||
return transform_adsb_lol(await _get_json(url))
|
return transform_adsb_lol(await _get_json(url))
|
||||||
|
|
||||||
rows = await _ttl_get(cache_key, 8.0, _load)
|
rows = await _ttl_get(cache_key, 8.0, _load)
|
||||||
|
from ws_manager import manager
|
||||||
|
from tracks import record_position
|
||||||
|
from geofence import record_and_notify
|
||||||
|
aircraft_last_known.clear()
|
||||||
|
for m in rows:
|
||||||
|
aircraft_last_known[str(m.get("id"))] = m
|
||||||
|
mlat, mlon = m.get("lat"), m.get("lon")
|
||||||
|
if mlat is None or mlon is None:
|
||||||
|
continue
|
||||||
|
await record_position("aircraft", m)
|
||||||
|
if manager.has_clients():
|
||||||
|
await manager.publish_point("adsb", m, lat=mlat, lon=mlon)
|
||||||
|
await record_and_notify(
|
||||||
|
source_kind="adsb", entity_id=str(m.get("id")),
|
||||||
|
lat=mlat, lon=mlon, payload=m,
|
||||||
|
)
|
||||||
|
if fire_last_known:
|
||||||
|
from fire_aircraft import correlate_and_notify
|
||||||
|
await correlate_and_notify(fire_last_known, rows)
|
||||||
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -613,7 +635,7 @@ async def upsert_vessel(marker: dict) -> None:
|
||||||
label = marker.get("label")
|
label = marker.get("label")
|
||||||
if not label or label == vid:
|
if not label or label == vid:
|
||||||
label = prev.get("label") or vid
|
label = prev.get("label") or vid
|
||||||
vessel_last_known[vid] = {
|
stored = {
|
||||||
**to_marker(
|
**to_marker(
|
||||||
vid, lat, lon,
|
vid, lat, lon,
|
||||||
heading=marker.get("heading") if marker.get("heading") is not None else prev.get("heading"),
|
heading=marker.get("heading") if marker.get("heading") is not None else prev.get("heading"),
|
||||||
|
|
@ -623,6 +645,7 @@ async def upsert_vessel(marker: dict) -> None:
|
||||||
),
|
),
|
||||||
"seen_at": datetime.now(timezone.utc).isoformat(),
|
"seen_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
vessel_last_known[vid] = stored
|
||||||
if len(vessel_last_known) > _MAX_VESSELS:
|
if len(vessel_last_known) > _MAX_VESSELS:
|
||||||
excess = len(vessel_last_known) - int(_MAX_VESSELS * 0.9)
|
excess = len(vessel_last_known) - int(_MAX_VESSELS * 0.9)
|
||||||
oldest = sorted(
|
oldest = sorted(
|
||||||
|
|
@ -631,6 +654,15 @@ async def upsert_vessel(marker: dict) -> None:
|
||||||
)[:excess]
|
)[:excess]
|
||||||
for k in oldest:
|
for k in oldest:
|
||||||
vessel_last_known.pop(k, None)
|
vessel_last_known.pop(k, None)
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
from ws_manager import manager
|
||||||
|
from tracks import record_position
|
||||||
|
from geofence import record_and_notify
|
||||||
|
await manager.publish_point("ais", stored, lat=lat, lon=lon)
|
||||||
|
await record_position("vessel", stored)
|
||||||
|
await record_and_notify(
|
||||||
|
source_kind="ais", entity_id=vid, lat=lat, lon=lon, payload=stored,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wfigs_params(bbox: str | None, *, offset_m: float = 250.0) -> dict:
|
def _wfigs_params(bbox: str | None, *, offset_m: float = 250.0) -> dict:
|
||||||
|
|
@ -662,6 +694,10 @@ async def fetch_fire_incidents(bbox: str | None, limit: int = DEFAULT_LIMIT) ->
|
||||||
return transform_wfigs_incidents(await _get_json(WFIGS_INCIDENTS, params))
|
return transform_wfigs_incidents(await _get_json(WFIGS_INCIDENTS, params))
|
||||||
|
|
||||||
rows = await _ttl_get(f"wfigs:inc:{bbox_cell_key(bbox)}", 600.0, _load)
|
rows = await _ttl_get(f"wfigs:inc:{bbox_cell_key(bbox)}", 600.0, _load)
|
||||||
|
fire_last_known[:] = list(rows)
|
||||||
|
if rows and aircraft_last_known:
|
||||||
|
from fire_aircraft import correlate_and_notify
|
||||||
|
await correlate_and_notify(rows, list(aircraft_last_known.values()))
|
||||||
return rows[:limit]
|
return rows[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
152
app/main.py
152
app/main.py
|
|
@ -21,7 +21,7 @@ from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -41,6 +41,7 @@ from schemas import (
|
||||||
KeyOut, KeyValueIn,
|
KeyOut, KeyValueIn,
|
||||||
SearchResult, SentimentSummary, SourceType,
|
SearchResult, SentimentSummary, SourceType,
|
||||||
SearchQuery, TimelinePoint, VesselBboxUpdate,
|
SearchQuery, TimelinePoint, VesselBboxUpdate,
|
||||||
|
GeofenceCreate, GeofenceUpdate,
|
||||||
)
|
)
|
||||||
from ingestor import ingest_event, fetch_and_process
|
from ingestor import ingest_event, fetch_and_process
|
||||||
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
|
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
|
||||||
|
|
@ -61,6 +62,11 @@ async def _lifespan(app: FastAPI):
|
||||||
await init_extensions()
|
await init_extensions()
|
||||||
from live_layers import close_http, init_http
|
from live_layers import close_http, init_http
|
||||||
await init_http()
|
await init_http()
|
||||||
|
try:
|
||||||
|
from geofence import refresh_cache
|
||||||
|
await refresh_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
from config import AISSTREAM_IN_APP
|
from config import AISSTREAM_IN_APP
|
||||||
ais_task = None
|
ais_task = None
|
||||||
if AISSTREAM_IN_APP:
|
if AISSTREAM_IN_APP:
|
||||||
|
|
@ -664,6 +670,56 @@ async def trigger_social_ingest(query: str = "", max_items: int = 50):
|
||||||
return {"status": "ok", "signals_ingested": count}
|
return {"status": "ok", "signals_ingested": count}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ingest/masscan")
|
||||||
|
async def trigger_masscan(background_tasks: BackgroundTasks):
|
||||||
|
"""Queue one masscan pass at ≤200 pps. Does not block the request on the scan."""
|
||||||
|
from bg_jobs import MASSCAN_PPS_CAP, schedule_masscan_pass
|
||||||
|
|
||||||
|
async def _kick() -> None:
|
||||||
|
schedule_masscan_pass()
|
||||||
|
|
||||||
|
background_tasks.add_task(_kick)
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "accepted", "rate_pps": MASSCAN_PPS_CAP},
|
||||||
|
status_code=202,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/live")
|
||||||
|
async def live_ws(ws: WebSocket):
|
||||||
|
"""Viewport-filtered AIS/ADS-B fan-out. Client sends {type:viewport,bbox}."""
|
||||||
|
from ws_manager import manager
|
||||||
|
|
||||||
|
client_id = str(id(ws))
|
||||||
|
await ws.accept()
|
||||||
|
queue = manager.register(client_id)
|
||||||
|
|
||||||
|
async def _pump() -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await queue.get()
|
||||||
|
await ws.send_json(msg)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
|
||||||
|
pump = asyncio.create_task(_pump())
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.receive_json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
if data.get("type") == "viewport" and data.get("bbox"):
|
||||||
|
try:
|
||||||
|
manager.set_viewport(client_id, parse_bbox(str(data["bbox"])))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
pump.cancel()
|
||||||
|
manager.unregister(client_id)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/ingest/process")
|
@app.post("/api/ingest/process")
|
||||||
async def trigger_nats_processing(batch_size: int = 100):
|
async def trigger_nats_processing(batch_size: int = 100):
|
||||||
"""Process pending NATS JetStream messages."""
|
"""Process pending NATS JetStream messages."""
|
||||||
|
|
@ -1135,10 +1191,15 @@ async def map_radar():
|
||||||
async def list_aircraft(
|
async def list_aircraft(
|
||||||
bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat"),
|
bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat"),
|
||||||
limit: int = Query(2000, ge=1, le=5000),
|
limit: int = Query(2000, ge=1, le=5000),
|
||||||
|
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
|
||||||
):
|
):
|
||||||
"""Viewport ADS-B last-known (ADSB.lol). Requires bbox; radius clamped ≤ 150 nm."""
|
"""Viewport ADS-B last-known (ADSB.lol). Requires bbox; radius clamped ≤ 150 nm."""
|
||||||
_parse_bbox_query(bbox)
|
_parse_bbox_query(bbox)
|
||||||
try:
|
try:
|
||||||
|
from tracks import fetch_positions_at, parse_timestamp
|
||||||
|
ts = parse_timestamp(timestamp)
|
||||||
|
if ts is not None:
|
||||||
|
return overlay_json(await fetch_positions_at("aircraft", ts, bbox, limit), 5)
|
||||||
return overlay_json(await fetch_aircraft(bbox, limit), 5)
|
return overlay_json(await fetch_aircraft(bbox, limit), 5)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, str(exc)) from exc
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
|
@ -1166,11 +1227,16 @@ async def list_trains(
|
||||||
async def list_vessels(
|
async def list_vessels(
|
||||||
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
|
||||||
limit: int = Query(2000, ge=1, le=5000),
|
limit: int = Query(2000, ge=1, le=5000),
|
||||||
|
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
|
||||||
):
|
):
|
||||||
"""AIS last-known from the server-side AISStream worker. Empty without a key."""
|
"""AIS last-known from the server-side AISStream worker. Empty without a key."""
|
||||||
if bbox:
|
if bbox:
|
||||||
_parse_bbox_query(bbox)
|
_parse_bbox_query(bbox)
|
||||||
try:
|
try:
|
||||||
|
from tracks import fetch_positions_at, parse_timestamp
|
||||||
|
ts = parse_timestamp(timestamp)
|
||||||
|
if ts is not None:
|
||||||
|
return overlay_json(await fetch_positions_at("vessel", ts, bbox, limit), 5)
|
||||||
return overlay_json(await fetch_vessels(bbox, limit), 5)
|
return overlay_json(await fetch_vessels(bbox, limit), 5)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, str(exc)) from exc
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
|
@ -1204,6 +1270,90 @@ async def vessels_subscribe(payload: VesselBboxUpdate):
|
||||||
return {"ok": True, "bbox": raw}
|
return {"ok": True, "bbox": raw}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tracks/range")
|
||||||
|
async def tracks_range():
|
||||||
|
"""Earliest/latest 1-minute track buckets for the DVR slider."""
|
||||||
|
from tracks import track_range
|
||||||
|
return await track_range()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/geofences")
|
||||||
|
async def api_list_geofences():
|
||||||
|
"""Drawn GeoJSON polygons. Not /api/alerts (entity/keyword)."""
|
||||||
|
from geofence import list_geofences
|
||||||
|
return await list_geofences()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/geofences", status_code=201)
|
||||||
|
async def api_create_geofence(payload: GeofenceCreate):
|
||||||
|
from geofence import create_geofence, validate_polygon_geojson
|
||||||
|
try:
|
||||||
|
validate_polygon_geojson(payload.geojson)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
try:
|
||||||
|
return await create_geofence(payload.name, payload.geojson, payload.active)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(503, f"geofence persist failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/geofences/{gid}")
|
||||||
|
async def api_update_geofence(gid: str, payload: GeofenceUpdate):
|
||||||
|
from geofence import update_geofence, validate_polygon_geojson
|
||||||
|
if payload.geojson is not None:
|
||||||
|
try:
|
||||||
|
validate_polygon_geojson(payload.geojson)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
row = await update_geofence(
|
||||||
|
gid, name=payload.name, geojson=payload.geojson, active=payload.active,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "geofence not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/geofences/{gid}", status_code=204)
|
||||||
|
async def api_delete_geofence(gid: str):
|
||||||
|
from geofence import delete_geofence
|
||||||
|
await delete_geofence(gid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/geofence-alerts")
|
||||||
|
async def api_geofence_alerts(limit: int = Query(100, ge=1, le=500)):
|
||||||
|
from sqlalchemy import text as sql_text
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(sql_text(
|
||||||
|
"""
|
||||||
|
SELECT id::text, geofence_id::text, source_kind, entity_id,
|
||||||
|
lat, lon, payload, created_at
|
||||||
|
FROM geofence_alerts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
), {"limit": limit})).mappings().all()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
item = dict(r)
|
||||||
|
if item.get("created_at") is not None:
|
||||||
|
item["created_at"] = item["created_at"].isoformat()
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/fire-aircraft")
|
||||||
|
async def api_fire_aircraft(limit: int = Query(200, ge=1, le=1000)):
|
||||||
|
"""Persisted firefighting ADS-B × wildfire correlations (20 mi)."""
|
||||||
|
from fire_aircraft import recent_hits
|
||||||
|
return await recent_hits(limit)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/fire-incidents")
|
@app.get("/api/fire-incidents")
|
||||||
async def list_fire_incidents(
|
async def list_fire_incidents(
|
||||||
bbox: str | None = Query(None),
|
bbox: str | None = Query(None),
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ feedparser>=6.0
|
||||||
python-dateutil>=2.9
|
python-dateutil>=2.9
|
||||||
structlog>=24.4
|
structlog>=24.4
|
||||||
websockets>=14
|
websockets>=14
|
||||||
|
cachetools>=5.5
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,11 @@ async def main() -> None:
|
||||||
"ingester starting (rss=%d feeds, gdelt_q=%r, quakes=%s, fires=%s, interval=%ss)",
|
"ingester starting (rss=%d feeds, gdelt_q=%r, quakes=%s, fires=%s, interval=%ss)",
|
||||||
len(RSS_URLS), GDELT_QUERY, ENABLE_QUAKES, ENABLE_FIRES, INTERVAL,
|
len(RSS_URLS), GDELT_QUERY, ENABLE_QUAKES, ENABLE_FIRES, INTERVAL,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from geofence import refresh_cache
|
||||||
|
await refresh_cache()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("geofence cache refresh failed (ST_Intersects still runs on ingest)")
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task] = []
|
||||||
if ENABLE_FIRES:
|
if ENABLE_FIRES:
|
||||||
# Fire ingest only starts once FIRMS_MAP_KEY is set (ingest_fires logs
|
# Fire ingest only starts once FIRMS_MAP_KEY is set (ingest_fires logs
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,15 @@ class VesselBboxUpdate(BaseModel):
|
||||||
|
|
||||||
bbox: str | None = None
|
bbox: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GeofenceCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
geojson: dict
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class GeofenceUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
geojson: Optional[dict] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import feedparser
|
||||||
import nats
|
import nats
|
||||||
|
|
||||||
from config import NATS_URL
|
from config import NATS_URL
|
||||||
|
from upstream_cache import rss_cache
|
||||||
|
|
||||||
logger = logging.getLogger("osint.sources")
|
logger = logging.getLogger("osint.sources")
|
||||||
|
|
||||||
|
|
@ -40,12 +41,16 @@ async def publish_event(subject: str, event: dict):
|
||||||
|
|
||||||
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
# ─── RSS Feed Ingestor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def ingest_rss_feed(feed_url: str):
|
async def ingest_rss_feed(feed_url: str, source_id: str | None = None):
|
||||||
"""Fetch and parse an RSS feed, publish items to NATS."""
|
"""Fetch and parse an RSS feed, publish items to NATS."""
|
||||||
|
text = rss_cache.get(feed_url)
|
||||||
|
if text is None:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
resp = await client.get(feed_url)
|
resp = await client.get(feed_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
feed = feedparser.parse(resp.text)
|
text = resp.text
|
||||||
|
rss_cache[feed_url] = text
|
||||||
|
feed = feedparser.parse(text)
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for entry in feed.entries[:100]: # max 100 per run
|
for entry in feed.entries[:100]: # max 100 per run
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,26 @@
|
||||||
.hud-chip .c.cams { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
.hud-chip .c.cams { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
.hud-chip .c.blips { background: var(--magenta); box-shadow: 0 0 6px var(--magenta); }
|
.hud-chip .c.blips { background: var(--magenta); box-shadow: 0 0 6px var(--magenta); }
|
||||||
.hud-chip.off { opacity: 0.4; }
|
.hud-chip.off { opacity: 0.4; }
|
||||||
|
#dvr-bar {
|
||||||
|
position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%);
|
||||||
|
z-index: 520; display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
background: rgba(6,11,20,0.86); border: 1px solid var(--line-hi);
|
||||||
|
border-radius: 4px; padding: 0.28rem 0.7rem;
|
||||||
|
font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted);
|
||||||
|
min-width: 280px; max-width: 52%;
|
||||||
|
}
|
||||||
|
#dvr-bar input[type=range] { flex: 1; accent-color: var(--cyan); }
|
||||||
|
#dvr-bar.live #dvr-live { color: var(--green); text-shadow: 0 0 8px rgba(83,240,165,0.5); }
|
||||||
|
#dvr-toast {
|
||||||
|
position: absolute; top: 12px; right: 12px; z-index: 700;
|
||||||
|
max-width: 280px; pointer-events: none;
|
||||||
|
}
|
||||||
|
#dvr-toast .gf-alert {
|
||||||
|
background: rgba(255,46,151,0.16); border: 1px solid var(--magenta);
|
||||||
|
color: var(--text); font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.68rem; padding: 0.4rem 0.6rem; margin-bottom: 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Layer control panel (dark HUD) ── */
|
/* ── Layer control panel (dark HUD) ── */
|
||||||
#layer-panel {
|
#layer-panel {
|
||||||
|
|
@ -754,6 +774,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-note">Needs AISSTREAM_API_KEY in Keys.</div>
|
<div class="lp-note">Needs AISSTREAM_API_KEY in Keys.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lp-layer">
|
||||||
|
<div class="lp-row">
|
||||||
|
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button>
|
||||||
|
</div>
|
||||||
|
<div class="lp-note">No Leaflet.Draw — click vertices, double-click to close. Saved to POST /api/geofences.</div>
|
||||||
|
</div>
|
||||||
<div class="lp-layer">
|
<div class="lp-layer">
|
||||||
<div class="lp-row">
|
<div class="lp-row">
|
||||||
<label class="lp-name"><input type="checkbox" id="lp-storms-on" checked onchange="toggleStorms()"> <span class="lp-dot storms"></span> NHC Storms</label>
|
<label class="lp-name"><input type="checkbox" id="lp-storms-on" checked onchange="toggleStorms()"> <span class="lp-dot storms"></span> NHC Storms</label>
|
||||||
|
|
@ -776,6 +802,12 @@
|
||||||
<div class="hud-chip" id="hud-cams"><span class="c cams"></span>CAMS <b id="hud-cams-n">—</b></div>
|
<div class="hud-chip" id="hud-cams"><span class="c cams"></span>CAMS <b id="hud-cams-n">—</b></div>
|
||||||
<div class="hud-chip" id="hud-blips"><span class="c blips"></span>BLIPS <b id="hud-blips-n">—</b></div>
|
<div class="hud-chip" id="hud-blips"><span class="c blips"></span>BLIPS <b id="hud-blips-n">—</b></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dvr-bar" class="live">
|
||||||
|
<button class="btn" id="dvr-live" type="button" onclick="dvrGoLive()">LIVE</button>
|
||||||
|
<input type="range" id="dvr-slider" min="0" max="360" value="360" oninput="dvrScrub(this.value)" aria-label="DVR time">
|
||||||
|
<span id="dvr-label">live</span>
|
||||||
|
</div>
|
||||||
|
<div id="dvr-toast"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1658,6 +1690,76 @@ function bboxCell() {
|
||||||
if (!map) return '';
|
if (!map) return '';
|
||||||
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
|
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
|
||||||
}
|
}
|
||||||
|
let liveWs = null;
|
||||||
|
const FF_ICAO = new Set(['AT802','AT8T','AT8P','C130','C30J','C130J','DC10','MD10','MD87','S64','UH60','S70','CL415','CL215','B350','OV10','C208']);
|
||||||
|
let firefighterHex = new Set();
|
||||||
|
let dvrTs = null;
|
||||||
|
let dvrMinMs = 0, dvrMaxMs = 0;
|
||||||
|
let gfDrawOn = false, gfVerts = [], gfLayer = null, gfSaved = null;
|
||||||
|
function liveWsUrl() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return proto + '//' + location.host + '/ws/live';
|
||||||
|
}
|
||||||
|
function sendLiveViewport() {
|
||||||
|
if (!liveWs || liveWs.readyState !== 1 || !map) return;
|
||||||
|
liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() }));
|
||||||
|
}
|
||||||
|
function upsertLivePoint(group, p, colorFn, feed) {
|
||||||
|
if (!map || !p || p.id == null || p.lat == null || p.lon == null) return group;
|
||||||
|
if (!group || !map.hasLayer(group) || !group._osintById) {
|
||||||
|
return renderPoints(group, [p], colorFn, true, feed);
|
||||||
|
}
|
||||||
|
const id = String(p.id);
|
||||||
|
const col = sanitizeColor(colorFn(p), '#35e0ff');
|
||||||
|
let m = group._osintById.get(id);
|
||||||
|
if (m) {
|
||||||
|
m.setLatLng([p.lat, p.lon]);
|
||||||
|
if (feed) m.setIcon(feedIcon(feed, col, p.heading));
|
||||||
|
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
|
||||||
|
} else {
|
||||||
|
m = makePointMarker(p, colorFn, feed, pointCanvas());
|
||||||
|
if (m) { group.addLayer(m); group._osintById.set(id, m); }
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
function applyLiveMarker(kind, p) {
|
||||||
|
if (!p) return;
|
||||||
|
if (kind === 'ais' && vesselsOn && map && map.getZoom() > 3 && !dvrTs) {
|
||||||
|
vesselsGroup = upsertLivePoint(vesselsGroup, p, q => {
|
||||||
|
const sog = Number(q.speed || 0);
|
||||||
|
return sog > 0.5 ? '#2dd4bf' : '#64748b';
|
||||||
|
}, 'vessel');
|
||||||
|
} else if (kind === 'adsb' && acOn && map && map.getZoom() > 3 && !dvrTs) {
|
||||||
|
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
|
||||||
|
} else if (kind === 'geofence_alert') {
|
||||||
|
showGeofenceToast(p);
|
||||||
|
} else if (kind === 'fire_aircraft') {
|
||||||
|
firefighterHex.add(String(p.aircraft_hex || ''));
|
||||||
|
if (acOn && p.aircraft_lat != null) {
|
||||||
|
acGroup = upsertLivePoint(acGroup, {
|
||||||
|
id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon,
|
||||||
|
label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true },
|
||||||
|
}, q => acColor(q), 'ac');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function acColor(p) {
|
||||||
|
const t = String(((p.extra || {}).type || '')).toUpperCase();
|
||||||
|
if ((p.extra || {}).firefighter || firefighterHex.has(String(p.id)) || FF_ICAO.has(t)) return '#fb923c';
|
||||||
|
return altColor((p.extra || {}).alt_baro);
|
||||||
|
}
|
||||||
|
function connectLiveWs() {
|
||||||
|
if (liveWs && (liveWs.readyState === 0 || liveWs.readyState === 1)) return;
|
||||||
|
try { liveWs = new WebSocket(liveWsUrl()); } catch (e) { return; }
|
||||||
|
liveWs.onopen = () => sendLiveViewport();
|
||||||
|
liveWs.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
applyLiveMarker(msg.type, msg.payload);
|
||||||
|
} catch (e) { /* ignore malformed */ }
|
||||||
|
};
|
||||||
|
liveWs.onclose = () => { liveWs = null; setTimeout(connectLiveWs, 4000); };
|
||||||
|
}
|
||||||
function overlayFetch(url) {
|
function overlayFetch(url) {
|
||||||
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
|
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
|
||||||
}
|
}
|
||||||
|
|
@ -1754,7 +1856,8 @@ async function initMap() {
|
||||||
if (camsOn) loadCams();
|
if (camsOn) loadCams();
|
||||||
if (blipsOn) loadBlips();
|
if (blipsOn) loadBlips();
|
||||||
refreshLiveOverlays();
|
refreshLiveOverlays();
|
||||||
}, 300);
|
sendLiveViewport();
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
// Static Blue Marble has no time-domain fetch — don't block overlays on GIBS.
|
// Static Blue Marble has no time-domain fetch — don't block overlays on GIBS.
|
||||||
mapLayerChanged();
|
mapLayerChanged();
|
||||||
|
|
@ -1778,6 +1881,7 @@ async function initMap() {
|
||||||
if (camsOn) loadCams();
|
if (camsOn) loadCams();
|
||||||
if (blipsOn) loadBlips();
|
if (blipsOn) loadBlips();
|
||||||
refreshLiveOverlays();
|
refreshLiveOverlays();
|
||||||
|
connectLiveWs();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
||||||
console.error('Map init failed', e);
|
console.error('Map init failed', e);
|
||||||
|
|
@ -2226,6 +2330,8 @@ function refreshLiveOverlays() {
|
||||||
if (trainsOn) loadTrains();
|
if (trainsOn) loadTrains();
|
||||||
if (vesselsOn) loadVessels();
|
if (vesselsOn) loadVessels();
|
||||||
if (stormsOn) loadStorms();
|
if (stormsOn) loadStorms();
|
||||||
|
loadGeofences();
|
||||||
|
loadFireAircraftHits();
|
||||||
}
|
}
|
||||||
function addExtraAttrib(html) {
|
function addExtraAttrib(html) {
|
||||||
if (!map || !html || extraAttribs.has(html)) return;
|
if (!map || !html || extraAttribs.has(html)) return;
|
||||||
|
|
@ -2554,10 +2660,10 @@ async function loadAircraft() {
|
||||||
}
|
}
|
||||||
const req = ++overlayReq.ac;
|
const req = ++overlayReq.ac;
|
||||||
try {
|
try {
|
||||||
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}`);
|
const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}${dvrQs()}`);
|
||||||
const pts = await r.json();
|
const pts = await r.json();
|
||||||
if (req !== overlayReq.ac) return;
|
if (req !== overlayReq.ac) return;
|
||||||
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true, 'ac');
|
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac');
|
||||||
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
|
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
|
||||||
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
|
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2622,7 +2728,7 @@ async function loadVessels() {
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}`);
|
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${dvrQs()}`);
|
||||||
const pts = await r.json();
|
const pts = await r.json();
|
||||||
if (req !== overlayReq.vessels) return;
|
if (req !== overlayReq.vessels) return;
|
||||||
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
|
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
|
||||||
|
|
@ -2674,6 +2780,122 @@ initMap();
|
||||||
// contend with the first bbox burst. Payload is slim (no article bodies).
|
// contend with the first bbox burst. Payload is slim (no article bodies).
|
||||||
setTimeout(() => loadNews(true), 400);
|
setTimeout(() => loadNews(true), 400);
|
||||||
setInterval(checkHealth, 30000);
|
setInterval(checkHealth, 30000);
|
||||||
|
|
||||||
|
/* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */
|
||||||
|
function dvrQs() { return dvrTs ? `×tamp=${encodeURIComponent(dvrTs)}` : ''; }
|
||||||
|
function dvrGoLive() {
|
||||||
|
dvrTs = null;
|
||||||
|
const bar = document.getElementById('dvr-bar');
|
||||||
|
if (bar) bar.classList.add('live');
|
||||||
|
const sl = document.getElementById('dvr-slider');
|
||||||
|
if (sl) sl.value = sl.max;
|
||||||
|
const lab = document.getElementById('dvr-label');
|
||||||
|
if (lab) lab.textContent = 'live';
|
||||||
|
if (acOn) loadAircraft();
|
||||||
|
if (vesselsOn) loadVessels();
|
||||||
|
}
|
||||||
|
function dvrScrub(v) {
|
||||||
|
if (!dvrMaxMs) { dvrGoLive(); return; }
|
||||||
|
const ms = dvrMinMs + (Number(v) / 360) * (dvrMaxMs - dvrMinMs);
|
||||||
|
dvrTs = new Date(ms).toISOString();
|
||||||
|
const bar = document.getElementById('dvr-bar');
|
||||||
|
if (bar) bar.classList.toggle('live', Number(v) >= 359);
|
||||||
|
if (Number(v) >= 359) { dvrGoLive(); return; }
|
||||||
|
const lab = document.getElementById('dvr-label');
|
||||||
|
if (lab) lab.textContent = dvrTs.slice(11, 16) + 'Z';
|
||||||
|
if (acOn) loadAircraft();
|
||||||
|
if (vesselsOn) loadVessels();
|
||||||
|
}
|
||||||
|
async function initDvrRange() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/tracks/range`);
|
||||||
|
const b = await r.json();
|
||||||
|
dvrMinMs = Date.parse(b.min);
|
||||||
|
dvrMaxMs = Date.parse(b.max);
|
||||||
|
if (!dvrMinMs || !dvrMaxMs) {
|
||||||
|
dvrMaxMs = Date.now();
|
||||||
|
dvrMinMs = dvrMaxMs - 6 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dvrMaxMs = Date.now();
|
||||||
|
dvrMinMs = dvrMaxMs - 6 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showGeofenceToast(p) {
|
||||||
|
const box = document.getElementById('dvr-toast');
|
||||||
|
if (!box) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'gf-alert';
|
||||||
|
el.textContent = `GEOFENCE ${p.geofence_name || ''} · ${p.source_kind} ${p.entity_id || ''}`;
|
||||||
|
box.prepend(el);
|
||||||
|
setTimeout(() => el.remove(), 8000);
|
||||||
|
}
|
||||||
|
function toggleGeofenceDraw() {
|
||||||
|
gfDrawOn = !gfDrawOn;
|
||||||
|
gfVerts = [];
|
||||||
|
if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; }
|
||||||
|
const btn = document.getElementById('gf-draw');
|
||||||
|
if (btn) btn.textContent = gfDrawOn ? 'Click map… (dbl-click close)' : 'Draw geofence';
|
||||||
|
if (!map) return;
|
||||||
|
if (gfDrawOn) {
|
||||||
|
map.getContainer().style.cursor = 'crosshair';
|
||||||
|
map.on('click', onGfClick);
|
||||||
|
map.on('dblclick', onGfClose);
|
||||||
|
} else {
|
||||||
|
map.getContainer().style.cursor = '';
|
||||||
|
map.off('click', onGfClick);
|
||||||
|
map.off('dblclick', onGfClose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onGfClick(e) {
|
||||||
|
if (!gfDrawOn) return;
|
||||||
|
L.DomEvent.stop(e);
|
||||||
|
gfVerts.push([e.latlng.lng, e.latlng.lat]);
|
||||||
|
if (gfLayer && map) map.removeLayer(gfLayer);
|
||||||
|
if (gfVerts.length >= 2) {
|
||||||
|
gfLayer = L.polyline(gfVerts.map(v => [v[1], v[0]]), { color: '#ff2e97', weight: 2 }).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onGfClose(e) {
|
||||||
|
if (!gfDrawOn) return;
|
||||||
|
L.DomEvent.stop(e);
|
||||||
|
if (gfVerts.length < 3) return;
|
||||||
|
const ring = gfVerts.concat([gfVerts[0]]);
|
||||||
|
const geojson = { type: 'Polygon', coordinates: [ring] };
|
||||||
|
try {
|
||||||
|
await fetch(`${API}/api/geofences`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Fence ' + new Date().toISOString().slice(11, 19), geojson }),
|
||||||
|
});
|
||||||
|
} catch (err) { console.error('geofence save failed', err); }
|
||||||
|
toggleGeofenceDraw();
|
||||||
|
loadGeofences();
|
||||||
|
}
|
||||||
|
async function loadGeofences() {
|
||||||
|
if (!map) return;
|
||||||
|
try {
|
||||||
|
const r = await overlayFetch(`${API}/api/geofences`);
|
||||||
|
const rows = await r.json();
|
||||||
|
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
|
||||||
|
const feats = (Array.isArray(rows) ? rows : []).map(f => ({
|
||||||
|
type: 'Feature', properties: { name: f.name, id: f.id }, geometry: f.geojson,
|
||||||
|
}));
|
||||||
|
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
|
||||||
|
style: { color: '#ff2e97', weight: 2, fillOpacity: 0.08 },
|
||||||
|
}).addTo(map);
|
||||||
|
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
|
||||||
|
}
|
||||||
|
async function loadFireAircraftHits() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/fire-aircraft`);
|
||||||
|
const rows = await r.json();
|
||||||
|
(Array.isArray(rows) ? rows : []).forEach(h => {
|
||||||
|
if (h.aircraft_hex) firefighterHex.add(String(h.aircraft_hex));
|
||||||
|
});
|
||||||
|
} catch (e) { /* empty without table */ }
|
||||||
|
}
|
||||||
|
initDvrRange();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
245
app/tracks.py
Normal file
245
app/tracks.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
"""Timescale 1-minute track rollups for DVR playback.
|
||||||
|
|
||||||
|
Live overlays stay in memory. Historical `?timestamp=` reads the 1-minute
|
||||||
|
continuous aggregates (or an in-process downsample when the DB is down).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import async_session
|
||||||
|
from live_layers import parse_bbox, to_marker
|
||||||
|
|
||||||
|
|
||||||
|
TRACK_BUCKET = "1 minute"
|
||||||
|
Kind = Literal["vessel", "aircraft"]
|
||||||
|
|
||||||
|
_RAW_TABLE = {
|
||||||
|
"vessel": "vessel_positions",
|
||||||
|
"aircraft": "aircraft_positions",
|
||||||
|
}
|
||||||
|
_CAGG = {
|
||||||
|
"vessel": "vessel_tracks_1min",
|
||||||
|
"aircraft": "aircraft_tracks_1min",
|
||||||
|
}
|
||||||
|
_ID_COL = {
|
||||||
|
"vessel": "mmsi",
|
||||||
|
"aircraft": "hex",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last persist time per entity so AIS/ADS-B does not write every frame.
|
||||||
|
_last_write: dict[tuple[str, str], datetime] = {}
|
||||||
|
_MIN_WRITE_GAP = timedelta(seconds=20)
|
||||||
|
|
||||||
|
|
||||||
|
def minute_bucket(ts: datetime) -> datetime:
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
return ts.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def downsample_tracks(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Last sample per id per 1-minute bucket (mirrors the CAGG)."""
|
||||||
|
last: dict[tuple[str, datetime], dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
rid = str(row.get("id") or "")
|
||||||
|
ts = row.get("ts")
|
||||||
|
if not rid or not isinstance(ts, datetime):
|
||||||
|
continue
|
||||||
|
bucket = minute_bucket(ts)
|
||||||
|
key = (rid, bucket)
|
||||||
|
prev = last.get(key)
|
||||||
|
if prev is None or ts >= prev["ts"]:
|
||||||
|
last[key] = {**row, "id": rid, "bucket": bucket, "ts": ts}
|
||||||
|
out = []
|
||||||
|
for (_id, bucket), row in last.items():
|
||||||
|
out.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"bucket": bucket,
|
||||||
|
"lat": row.get("lat"),
|
||||||
|
"lon": row.get("lon"),
|
||||||
|
"heading": row.get("heading"),
|
||||||
|
"speed": row.get("speed"),
|
||||||
|
"label": row.get("label"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def positions_at_timestamp(rows: list[dict], ts: datetime) -> list[dict]:
|
||||||
|
"""Positions whose 1-minute bucket equals floor(ts)."""
|
||||||
|
want = minute_bucket(ts)
|
||||||
|
picked = [r for r in downsample_tracks(rows) if r["bucket"] == want]
|
||||||
|
return [
|
||||||
|
to_marker(
|
||||||
|
r["id"], r.get("lat"), r.get("lon"),
|
||||||
|
heading=r.get("heading"), speed=r.get("speed"),
|
||||||
|
label=r.get("label") or r["id"],
|
||||||
|
)
|
||||||
|
for r in picked
|
||||||
|
if r.get("lat") is not None and r.get("lon") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(value: str | datetime | None) -> datetime | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
ts = value
|
||||||
|
else:
|
||||||
|
raw = str(value).strip().replace("Z", "+00:00")
|
||||||
|
ts = datetime.fromisoformat(raw)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
||||||
|
async def record_position(kind: Kind, marker: dict, ts: datetime | None = None) -> bool:
|
||||||
|
"""Insert one sample into the raw hypertable (rate-limited)."""
|
||||||
|
vid = str(marker.get("id") or "")
|
||||||
|
lat, lon = marker.get("lat"), marker.get("lon")
|
||||||
|
if not vid or lat is None or lon is None:
|
||||||
|
return False
|
||||||
|
now = ts or datetime.now(timezone.utc)
|
||||||
|
key = (kind, vid)
|
||||||
|
prev = _last_write.get(key)
|
||||||
|
if prev is not None and now - prev < _MIN_WRITE_GAP:
|
||||||
|
return False
|
||||||
|
_last_write[key] = now
|
||||||
|
table = _RAW_TABLE[kind]
|
||||||
|
id_col = _ID_COL[kind]
|
||||||
|
extra = marker.get("extra") or {}
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
f"""
|
||||||
|
INSERT INTO {table} ({id_col}, ts, lat, lon, heading, speed, label, extra)
|
||||||
|
VALUES (:id, :ts, :lat, :lon, :heading, :speed, :label, CAST(:extra AS jsonb))
|
||||||
|
ON CONFLICT ({id_col}, ts) DO NOTHING
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": vid,
|
||||||
|
"ts": now,
|
||||||
|
"lat": float(lat),
|
||||||
|
"lon": float(lon),
|
||||||
|
"heading": marker.get("heading"),
|
||||||
|
"speed": marker.get("speed"),
|
||||||
|
"label": marker.get("label") or vid,
|
||||||
|
"extra": json.dumps(extra),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_positions_at(
|
||||||
|
kind: Kind,
|
||||||
|
ts: datetime,
|
||||||
|
bbox: str | None = None,
|
||||||
|
limit: int = 2000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read the 1-minute CAGG for the bucket containing ``ts``."""
|
||||||
|
bucket = minute_bucket(ts)
|
||||||
|
table = _CAGG[kind]
|
||||||
|
id_col = _ID_COL[kind]
|
||||||
|
where = "bucket = :bucket"
|
||||||
|
params: dict[str, Any] = {"bucket": bucket, "limit": limit}
|
||||||
|
if bbox:
|
||||||
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
||||||
|
where += " AND lon BETWEEN :minlon AND :maxlon AND lat BETWEEN :minlat AND :maxlat"
|
||||||
|
params.update(minlon=minlon, minlat=minlat, maxlon=maxlon, maxlat=maxlat)
|
||||||
|
sql = f"""
|
||||||
|
SELECT {id_col} AS id, lat, lon, heading, speed, label, bucket
|
||||||
|
FROM {table}
|
||||||
|
WHERE {where}
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(text(sql), params)).mappings().all()
|
||||||
|
points = [
|
||||||
|
to_marker(
|
||||||
|
r["id"], r["lat"], r["lon"],
|
||||||
|
heading=r["heading"], speed=r["speed"],
|
||||||
|
label=r["label"] or r["id"],
|
||||||
|
extra={"bucket": r["bucket"].isoformat() if r["bucket"] else None, "dvr": True},
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
if r["lat"] is not None and r["lon"] is not None
|
||||||
|
]
|
||||||
|
return points
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def track_range() -> dict:
|
||||||
|
"""Earliest/latest buckets across both CAGGs — slider bounds."""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
row = (await session.execute(text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
(SELECT min(bucket) FROM vessel_tracks_1min),
|
||||||
|
(SELECT min(bucket) FROM aircraft_tracks_1min)
|
||||||
|
) AS tmin,
|
||||||
|
GREATEST(
|
||||||
|
(SELECT max(bucket) FROM vessel_tracks_1min),
|
||||||
|
(SELECT max(bucket) FROM aircraft_tracks_1min)
|
||||||
|
) AS tmax
|
||||||
|
"""
|
||||||
|
))).mappings().first()
|
||||||
|
if not row or row["tmin"] is None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
return {"min": (now - timedelta(hours=6)).isoformat(), "max": now.isoformat()}
|
||||||
|
return {
|
||||||
|
"min": row["tmin"].isoformat(),
|
||||||
|
"max": row["tmax"].isoformat(),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
return {"min": (now - timedelta(hours=6)).isoformat(), "max": now.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
async def recent_markers(kind: Kind, limit: int = 2000) -> list[dict]:
|
||||||
|
"""Latest raw sample per id — used when in-process last-known is empty."""
|
||||||
|
table = _RAW_TABLE[kind]
|
||||||
|
id_col = _ID_COL[kind]
|
||||||
|
sql = f"""
|
||||||
|
SELECT DISTINCT ON ({id_col})
|
||||||
|
{id_col} AS id, lat, lon, heading, speed, label, extra
|
||||||
|
FROM {table}
|
||||||
|
WHERE ts > now() - interval '15 minutes'
|
||||||
|
ORDER BY {id_col}, ts DESC
|
||||||
|
LIMIT :limit
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(text(sql), {"limit": limit})).mappings().all()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
extra = r.get("extra") or {}
|
||||||
|
if isinstance(extra, str):
|
||||||
|
try:
|
||||||
|
extra = json.loads(extra)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
extra = {}
|
||||||
|
m = to_marker(
|
||||||
|
r["id"], r["lat"], r["lon"],
|
||||||
|
heading=r["heading"], speed=r["speed"],
|
||||||
|
label=r["label"] or r["id"],
|
||||||
|
extra=extra if isinstance(extra, dict) else {},
|
||||||
|
)
|
||||||
|
if m.get("lat") is not None and m.get("lon") is not None:
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
11
app/upstream_cache.py
Normal file
11
app/upstream_cache.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""In-process TTL caches for chatty upstreams (FIRMS, RSS). No Redis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
|
# FIRMS NRT updates every ~5–10 min; 5 min / 100 keys is enough for bbox×dataset.
|
||||||
|
firms_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
||||||
|
|
||||||
|
# News RSS: 1 minute is enough to absorb dashboard double-clicks / retries.
|
||||||
|
rss_cache: TTLCache = TTLCache(maxsize=100, ttl=60)
|
||||||
80
app/ws_manager.py
Normal file
80
app/ws_manager.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""In-memory WebSocket pub/sub with viewport filtering.
|
||||||
|
|
||||||
|
Zero extra deps. Ingest workers publish AIS/ADS-B points; only clients whose
|
||||||
|
current map bbox contains the point receive the payload. No Redis/Kafka.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_bbox(lon: float, lat: float, bbox: BBox | None) -> bool:
|
||||||
|
"""True if (lon, lat) sits inside an axis-aligned viewport."""
|
||||||
|
if bbox is None:
|
||||||
|
return False
|
||||||
|
minlon, minlat, maxlon, maxlat = bbox
|
||||||
|
return minlon <= lon <= maxlon and minlat <= lat <= maxlat
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""Maps Tailscale/browser clients → viewport bbox + per-client queue."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queues: dict[str, asyncio.Queue] = {}
|
||||||
|
self._viewports: dict[str, BBox] = {}
|
||||||
|
|
||||||
|
def register(self, client_id: str, maxsize: int = 256) -> asyncio.Queue:
|
||||||
|
q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
|
||||||
|
self._queues[client_id] = q
|
||||||
|
return q
|
||||||
|
|
||||||
|
def unregister(self, client_id: str) -> None:
|
||||||
|
self._queues.pop(client_id, None)
|
||||||
|
self._viewports.pop(client_id, None)
|
||||||
|
|
||||||
|
def set_viewport(self, client_id: str, bbox: BBox) -> None:
|
||||||
|
if client_id in self._queues:
|
||||||
|
self._viewports[client_id] = bbox
|
||||||
|
|
||||||
|
def viewport_of(self, client_id: str) -> BBox | None:
|
||||||
|
return self._viewports.get(client_id)
|
||||||
|
|
||||||
|
def has_clients(self) -> bool:
|
||||||
|
return bool(self._queues)
|
||||||
|
|
||||||
|
async def publish_point(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
) -> int:
|
||||||
|
"""Enqueue `{type, payload}` for clients whose viewport contains the point.
|
||||||
|
|
||||||
|
Drops the oldest queued message if a client's buffer is full so a slow
|
||||||
|
tab cannot stall ingest. Returns the number of clients that got a copy.
|
||||||
|
"""
|
||||||
|
msg = {"type": kind, "payload": payload}
|
||||||
|
sent = 0
|
||||||
|
for client_id, queue in list(self._queues.items()):
|
||||||
|
if not point_in_bbox(lon, lat, self._viewports.get(client_id)):
|
||||||
|
continue
|
||||||
|
if queue.full():
|
||||||
|
try:
|
||||||
|
queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
queue.put_nowait(msg)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
continue
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
@ -30,7 +30,16 @@ services:
|
||||||
POSTGRES_DB: ${DB_NAME:-osint_data}
|
POSTGRES_DB: ${DB_NAME:-osint_data}
|
||||||
# Ensure TimescaleDB is preloaded (conf.d drop-in may be ignored by the
|
# Ensure TimescaleDB is preloaded (conf.d drop-in may be ignored by the
|
||||||
# official image's runtime-generated postgresql.conf, so pass it explicitly).
|
# official image's runtime-generated postgresql.conf, so pass it explicitly).
|
||||||
command: ["-c", "shared_preload_libraries=timescaledb"]
|
# shared_buffers capped at 2GB for Pi 5 8GB / 4 cores.
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"-c", "shared_preload_libraries=timescaledb",
|
||||||
|
"-c", "shared_buffers=2GB",
|
||||||
|
]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 3G
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -124,6 +133,10 @@ services:
|
||||||
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
|
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health').status==200 else 1)\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health').status==200 else 1)\""]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
||||||
64
tests/test_bg_jobs.py
Normal file
64
tests/test_bg_jobs.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""masscan/ffmpeg stay off the request path (asyncio.create_task)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import bg_jobs
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_masscan_pass_returns_without_awaiting_scan(monkeypatch):
|
||||||
|
started = {"n": 0}
|
||||||
|
|
||||||
|
async def slow_pass():
|
||||||
|
started["n"] += 1
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bg_jobs, "_run_masscan_capped", slow_pass)
|
||||||
|
bg_jobs._masscan_task = None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
launched = bg_jobs.schedule_masscan_pass()
|
||||||
|
assert launched is True
|
||||||
|
# Must not have blocked for the 30s pass.
|
||||||
|
assert bg_jobs._masscan_task is not None
|
||||||
|
assert not bg_jobs._masscan_task.done()
|
||||||
|
launched2 = bg_jobs.schedule_masscan_pass()
|
||||||
|
assert launched2 is False # already running
|
||||||
|
bg_jobs._masscan_task.cancel()
|
||||||
|
try:
|
||||||
|
await bg_jobs._masscan_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
bg_jobs._masscan_task = None
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_masscan_rate_cap_is_200():
|
||||||
|
assert bg_jobs.MASSCAN_PPS_CAP == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_ffmpeg_snapshot_is_a_task_not_inline(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
async def fake_grab(url, timeout=8.0):
|
||||||
|
calls["n"] += 1
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return b"\xff\xd8fakejpeg"
|
||||||
|
|
||||||
|
monkeypatch.setattr(bg_jobs, "_ffmpeg_grab", fake_grab)
|
||||||
|
bg_jobs._ffmpeg_tasks.clear()
|
||||||
|
bg_jobs._ffmpeg_cache.clear()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
task = bg_jobs.schedule_ffmpeg_snapshot("rtsp://10.0.0.1/")
|
||||||
|
assert isinstance(task, asyncio.Task)
|
||||||
|
assert not task.done()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
66
tests/test_fire_aircraft.py
Normal file
66
tests/test_fire_aircraft.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""WFIGS/FIRMS × firefighting ADS-B correlation within 20 miles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fire_aircraft import (
|
||||||
|
FIREFIGHTER_ICAO,
|
||||||
|
RADIUS_MILES,
|
||||||
|
correlate_aircraft_to_fires,
|
||||||
|
is_firefighter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ac(hex_id, lat, lon, icao, **extra):
|
||||||
|
return {
|
||||||
|
"id": hex_id,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"label": hex_id,
|
||||||
|
"heading": 0,
|
||||||
|
"speed": 120,
|
||||||
|
"extra": {"type": icao, "hex": hex_id, **extra},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fire(name, lat, lon, **extra):
|
||||||
|
return {
|
||||||
|
"id": name,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"label": name,
|
||||||
|
"extra": extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_air_tractor_is_firefighter_airliner_is_not():
|
||||||
|
assert is_firefighter(_ac("aaa", 0, 0, "AT802")) is True
|
||||||
|
assert is_firefighter(_ac("bbb", 0, 0, "C130")) is True
|
||||||
|
assert is_firefighter(_ac("ccc", 0, 0, "B738")) is False
|
||||||
|
assert "AT802" in FIREFIGHTER_ICAO
|
||||||
|
|
||||||
|
|
||||||
|
def test_flags_tanker_within_20_miles_of_fire():
|
||||||
|
# ~10 miles north of a Piedmont fire
|
||||||
|
fire = _fire("Jones Gap", 35.00, -82.00)
|
||||||
|
tanker = _ac("acf001", 35.145, -82.00, "AT802")
|
||||||
|
airliner = _ac("a0b738", 35.145, -82.00, "B738")
|
||||||
|
far = _ac("acfar", 35.50, -82.00, "C130") # ~34 miles
|
||||||
|
hits = correlate_aircraft_to_fires([fire], [tanker, airliner, far])
|
||||||
|
assert RADIUS_MILES == 20.0
|
||||||
|
assert len(hits) == 1
|
||||||
|
h = hits[0]
|
||||||
|
assert h["aircraft_hex"] == "acf001"
|
||||||
|
assert h["fire_id"] == "Jones Gap"
|
||||||
|
assert h["aircraft_type"] == "AT802"
|
||||||
|
assert 0 < h["distance_mi"] <= 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_shape_has_reload_fields():
|
||||||
|
fire = _fire("Jones Gap", 35.00, -82.00, src="wfigs")
|
||||||
|
tanker = _ac("acf001", 35.10, -82.00, "S64")
|
||||||
|
hits = correlate_aircraft_to_fires([fire], [tanker])
|
||||||
|
assert set(hits[0]).issuperset({
|
||||||
|
"fire_id", "fire_lat", "fire_lon",
|
||||||
|
"aircraft_hex", "aircraft_type", "aircraft_lat", "aircraft_lon",
|
||||||
|
"distance_mi",
|
||||||
|
})
|
||||||
|
|
@ -85,3 +85,87 @@ def test_ingest_fire_row_drops_malformed(clean_fires):
|
||||||
assert await ingest_fire_row(make_fire_msg(acq_time="garbage")) is False
|
assert await ingest_fire_row(make_fire_msg(acq_time="garbage")) is False
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
class _InsertSession:
|
||||||
|
"""async_session stand-in: fire insert succeeds (rowcount=1)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rowcount = 1
|
||||||
|
|
||||||
|
async def execute(self, *a, **k):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_fire_row_geofence_when_cache_empty(monkeypatch):
|
||||||
|
"""Ingester process has empty geofence cache; still notify on FIRMS insert."""
|
||||||
|
import geofence
|
||||||
|
import ingestor
|
||||||
|
import live_layers
|
||||||
|
|
||||||
|
geofence._cache.clear()
|
||||||
|
live_layers.aircraft_last_known.clear()
|
||||||
|
notified = []
|
||||||
|
|
||||||
|
async def fake_record(**kw):
|
||||||
|
notified.append(kw)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
async def no_markers(*a, **k):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(ingestor, "async_session", _InsertSession)
|
||||||
|
monkeypatch.setattr(geofence, "record_and_notify", fake_record)
|
||||||
|
monkeypatch.setattr("tracks.recent_markers", no_markers)
|
||||||
|
|
||||||
|
assert asyncio.run(ingest_fire_row(make_fire_msg())) is True
|
||||||
|
assert len(notified) == 1
|
||||||
|
assert notified[0]["source_kind"] == "firms"
|
||||||
|
assert notified[0]["lat"] == 39.45678
|
||||||
|
assert notified[0]["lon"] == -121.12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_fire_row_correlates_from_hypertable_when_last_known_empty(monkeypatch):
|
||||||
|
"""FIRMS ingester has no ADS-B last-known; still correlate from aircraft_positions."""
|
||||||
|
import geofence
|
||||||
|
import ingestor
|
||||||
|
import live_layers
|
||||||
|
|
||||||
|
geofence._cache.clear()
|
||||||
|
live_layers.aircraft_last_known.clear()
|
||||||
|
correlated = []
|
||||||
|
|
||||||
|
async def fake_record(**kw):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def fake_recent(kind, limit=2000):
|
||||||
|
assert kind == "aircraft"
|
||||||
|
return [{
|
||||||
|
"id": "acf001",
|
||||||
|
"lat": 39.45,
|
||||||
|
"lon": -121.12,
|
||||||
|
"extra": {"type": "AT802"},
|
||||||
|
}]
|
||||||
|
|
||||||
|
async def fake_corr(fires, aircraft):
|
||||||
|
correlated.append((fires, aircraft))
|
||||||
|
return aircraft
|
||||||
|
|
||||||
|
monkeypatch.setattr(ingestor, "async_session", _InsertSession)
|
||||||
|
monkeypatch.setattr(geofence, "record_and_notify", fake_record)
|
||||||
|
monkeypatch.setattr("tracks.recent_markers", fake_recent)
|
||||||
|
monkeypatch.setattr("fire_aircraft.correlate_and_notify", fake_corr)
|
||||||
|
|
||||||
|
assert asyncio.run(ingest_fire_row(make_fire_msg())) is True
|
||||||
|
assert len(correlated) == 1
|
||||||
|
assert correlated[0][1][0]["id"] == "acf001"
|
||||||
|
assert correlated[0][0][0]["lat"] == 39.45678
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ def test_ingest_fires_idles_without_map_key(monkeypatch):
|
||||||
|
|
||||||
def test_ingest_fires_uses_keystore_key(monkeypatch):
|
def test_ingest_fires_uses_keystore_key(monkeypatch):
|
||||||
# Key saved via the dashboard Keys page (Postgres) is picked up.
|
# Key saved via the dashboard Keys page (Postgres) is picked up.
|
||||||
|
from upstream_cache import firms_cache
|
||||||
|
firms_cache.clear()
|
||||||
monkeypatch.setenv("FIRMS_MAP_KEY", "")
|
monkeypatch.setenv("FIRMS_MAP_KEY", "")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"fire_sources.get_api_key",
|
"fire_sources.get_api_key",
|
||||||
|
|
|
||||||
136
tests/test_geofence.py
Normal file
136
tests/test_geofence.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Geofence hit detection and WS alert routing (no Redis)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import geofence
|
||||||
|
from geofence import (
|
||||||
|
matching_geofences,
|
||||||
|
point_in_geojson,
|
||||||
|
validate_polygon_geojson,
|
||||||
|
)
|
||||||
|
from ws_manager import ConnectionManager
|
||||||
|
|
||||||
|
|
||||||
|
NC_BOX = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[
|
||||||
|
[-80.0, 35.0],
|
||||||
|
[-78.0, 35.0],
|
||||||
|
[-78.0, 36.0],
|
||||||
|
[-80.0, 36.0],
|
||||||
|
[-80.0, 35.0],
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_inside_polygon_hits():
|
||||||
|
assert point_in_geojson(-79.0, 35.5, NC_BOX) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_outside_polygon_misses():
|
||||||
|
assert point_in_geojson(-122.4, 37.7, NC_BOX) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_non_polygon():
|
||||||
|
try:
|
||||||
|
validate_polygon_geojson({"type": "Point", "coordinates": [-79.0, 35.5]})
|
||||||
|
assert False, "expected ValueError"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_geofences_only_active_hits():
|
||||||
|
fences = [
|
||||||
|
{"id": "a", "name": "NC", "active": True, "geojson": NC_BOX},
|
||||||
|
{"id": "b", "name": "off", "active": False, "geojson": NC_BOX},
|
||||||
|
]
|
||||||
|
hits = matching_geofences(-79.0, 35.5, fences)
|
||||||
|
assert [h["id"] for h in hits] == ["a"]
|
||||||
|
assert matching_geofences(-122.4, 37.7, fences) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_geofence_alert_fans_out_only_to_viewport_clients():
|
||||||
|
mgr = ConnectionManager()
|
||||||
|
q_nc = mgr.register("nc")
|
||||||
|
q_sf = mgr.register("sf")
|
||||||
|
mgr.set_viewport("nc", (-80.0, 35.0, -78.0, 36.0))
|
||||||
|
mgr.set_viewport("sf", (-123.0, 37.0, -121.0, 38.0))
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
payload = {
|
||||||
|
"geofence_id": "a",
|
||||||
|
"geofence_name": "NC",
|
||||||
|
"source_kind": "ais",
|
||||||
|
"entity_id": "366123456",
|
||||||
|
"lat": 35.5,
|
||||||
|
"lon": -79.0,
|
||||||
|
}
|
||||||
|
n = await mgr.publish_point("geofence_alert", payload, lat=35.5, lon=-79.0)
|
||||||
|
assert n == 1
|
||||||
|
msg = q_nc.get_nowait()
|
||||||
|
assert msg["type"] == "geofence_alert"
|
||||||
|
assert msg["payload"]["entity_id"] == "366123456"
|
||||||
|
assert q_sf.empty()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_and_notify_queries_postgis_when_cache_empty(monkeypatch):
|
||||||
|
"""FIRMS ingest in the ingester has an empty in-process cache — still ST_Intersects."""
|
||||||
|
import geofence
|
||||||
|
|
||||||
|
geofence._cache.clear()
|
||||||
|
geofence._recent_hits.clear()
|
||||||
|
st_called = []
|
||||||
|
|
||||||
|
async def fake_st(lon, lat):
|
||||||
|
st_called.append((lon, lat))
|
||||||
|
return [{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"name": "NC",
|
||||||
|
"geojson": NC_BOX,
|
||||||
|
"active": True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(geofence, "st_intersects", fake_st)
|
||||||
|
|
||||||
|
executed: list = []
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def execute(self, stmt, params=None):
|
||||||
|
executed.append(params or {})
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
executed.append("commit")
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(geofence, "async_session", FakeSession)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
from ws_manager import manager
|
||||||
|
q = manager.register("nc")
|
||||||
|
manager.set_viewport("nc", (-80.0, 35.0, -78.0, 36.0))
|
||||||
|
n = await geofence.record_and_notify(
|
||||||
|
source_kind="firms", entity_id="35.5,-79.0,N",
|
||||||
|
lat=35.5, lon=-79.0, payload={"satellite": "N"},
|
||||||
|
)
|
||||||
|
msg = None if q.empty() else q.get_nowait()
|
||||||
|
manager.unregister("nc")
|
||||||
|
return n, msg
|
||||||
|
|
||||||
|
n, msg = asyncio.run(run())
|
||||||
|
assert st_called == [(-79.0, 35.5)]
|
||||||
|
assert n == 1
|
||||||
|
assert msg["type"] == "geofence_alert"
|
||||||
|
assert msg["payload"]["source_kind"] == "firms"
|
||||||
|
inserts = [p for p in executed if isinstance(p, dict)]
|
||||||
|
assert inserts and inserts[0]["source_kind"] == "firms"
|
||||||
|
assert "commit" in executed
|
||||||
33
tests/test_phase1_guardrails.py
Normal file
33
tests/test_phase1_guardrails.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Phase 1 guardrails: compose limits, 500ms debounce, no extra brokers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_memory_limits_and_shared_buffers():
|
||||||
|
text = (ROOT / "docker-compose.yml").read_text()
|
||||||
|
assert "shared_buffers=2GB" in text
|
||||||
|
assert "shared_preload_libraries=timescaledb" in text
|
||||||
|
assert "memory: 3G" in text or "memory: 3GB" in text
|
||||||
|
assert "memory: 2G" in text or "memory: 2GB" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_moveend_debounced_500ms():
|
||||||
|
html = (ROOT / "app" / "static" / "index.html").read_text()
|
||||||
|
assert "map.on('moveend'" in html
|
||||||
|
# Existing 300ms debounce must be 500ms so pans don't spam bbox POSTs/WS.
|
||||||
|
assert "}, 500);" in html
|
||||||
|
assert "}, 300);" not in html.split("map.on('moveend'")[1][:800]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_redis_kafka_celery():
|
||||||
|
req = (ROOT / "app" / "requirements.txt").read_text().lower()
|
||||||
|
compose = (ROOT / "docker-compose.yml").read_text().lower()
|
||||||
|
for blob in (req, compose):
|
||||||
|
assert "redis" not in blob
|
||||||
|
assert "kafka" not in blob
|
||||||
|
assert "celery" not in blob
|
||||||
|
assert "cachetools" in req
|
||||||
51
tests/test_phase2_api.py
Normal file
51
tests/test_phase2_api.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Phase 2 API contracts (no Redis; geofences ≠ /api/alerts)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
BASE = "http://test"
|
||||||
|
|
||||||
|
|
||||||
|
async def _req(method: str, path: str, **kw) -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
||||||
|
return await client.request(method, path, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_geofence_post_rejects_point():
|
||||||
|
resp = asyncio.run(_req(
|
||||||
|
"POST", "/api/geofences",
|
||||||
|
json={"name": "nope", "geojson": {"type": "Point", "coordinates": [-79, 35]}},
|
||||||
|
))
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_geofences_list_does_not_collide_with_alerts():
|
||||||
|
resp = asyncio.run(_req("GET", "/api/geofences"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aircraft_timestamp_returns_list_not_live_error():
|
||||||
|
resp = asyncio.run(_req(
|
||||||
|
"GET", "/api/aircraft",
|
||||||
|
params={"bbox": "-80,35,-78,36", "timestamp": "2026-08-28T12:04:00Z"},
|
||||||
|
))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracks_range_has_bounds():
|
||||||
|
body = asyncio.run(_req("GET", "/api/tracks/range")).json()
|
||||||
|
assert "min" in body and "max" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_fire_aircraft_reload_endpoint():
|
||||||
|
resp = asyncio.run(_req("GET", "/api/fire-aircraft"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
50
tests/test_tracks.py
Normal file
50
tests/test_tracks.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""1-minute downsampled tracks for DVR playback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from tracks import (
|
||||||
|
TRACK_BUCKET,
|
||||||
|
downsample_tracks,
|
||||||
|
minute_bucket,
|
||||||
|
positions_at_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(h, m, s=0):
|
||||||
|
return datetime(2026, 8, 28, h, m, s, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_minute_bucket_floors_seconds():
|
||||||
|
assert minute_bucket(_ts(12, 4, 47)) == _ts(12, 4, 0)
|
||||||
|
assert TRACK_BUCKET == "1 minute"
|
||||||
|
|
||||||
|
|
||||||
|
def test_downsample_keeps_last_sample_per_id_per_minute():
|
||||||
|
rows = [
|
||||||
|
{"id": "a1", "ts": _ts(12, 4, 10), "lat": 35.0, "lon": -79.0},
|
||||||
|
{"id": "a1", "ts": _ts(12, 4, 50), "lat": 35.1, "lon": -79.1},
|
||||||
|
{"id": "a1", "ts": _ts(12, 5, 5), "lat": 35.2, "lon": -79.2},
|
||||||
|
{"id": "b2", "ts": _ts(12, 4, 20), "lat": 36.0, "lon": -80.0},
|
||||||
|
]
|
||||||
|
out = downsample_tracks(rows)
|
||||||
|
by = {(r["id"], r["bucket"]): r for r in out}
|
||||||
|
assert by[("a1", _ts(12, 4))]["lat"] == 35.1
|
||||||
|
assert by[("a1", _ts(12, 5))]["lat"] == 35.2
|
||||||
|
assert by[("b2", _ts(12, 4))]["lat"] == 36.0
|
||||||
|
assert len(out) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_at_timestamp_uses_that_minute_window():
|
||||||
|
rows = [
|
||||||
|
{"id": "a1", "ts": _ts(12, 4, 50), "lat": 35.1, "lon": -79.1, "heading": 90, "speed": 10, "label": "A1"},
|
||||||
|
{"id": "a1", "ts": _ts(12, 5, 5), "lat": 35.2, "lon": -79.2, "heading": 91, "speed": 11, "label": "A1"},
|
||||||
|
{"id": "b2", "ts": _ts(12, 4, 20), "lat": 36.0, "lon": -80.0, "heading": 0, "speed": 0, "label": "B2"},
|
||||||
|
]
|
||||||
|
at = positions_at_timestamp(rows, _ts(12, 4, 59))
|
||||||
|
ids = {p["id"]: p for p in at}
|
||||||
|
assert ids["a1"]["lat"] == 35.1
|
||||||
|
assert ids["b2"]["lat"] == 36.0
|
||||||
|
later = positions_at_timestamp(rows, _ts(12, 5, 30))
|
||||||
|
assert {p["id"]: p["lat"] for p in later} == {"a1": 35.2}
|
||||||
104
tests/test_upstream_cache.py
Normal file
104
tests/test_upstream_cache.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""FIRMS / RSS in-process TTLCache — no Redis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
|
from fire_sources import ingest_fires
|
||||||
|
from sources import ingest_rss_feed
|
||||||
|
from test_fire_sources import SAMPLE_CSV, _async_return
|
||||||
|
from upstream_cache import firms_cache, rss_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_firms_and_rss_caches_are_ttlcache():
|
||||||
|
assert isinstance(firms_cache, TTLCache)
|
||||||
|
assert firms_cache.maxsize == 100
|
||||||
|
assert firms_cache.ttl == 300
|
||||||
|
assert isinstance(rss_cache, TTLCache)
|
||||||
|
assert rss_cache.maxsize == 100
|
||||||
|
assert 60 <= rss_cache.ttl <= 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_fires_hits_http_once_within_ttl(monkeypatch):
|
||||||
|
firms_cache.clear()
|
||||||
|
monkeypatch.setenv("FIRMS_MAP_KEY", "k" * 32)
|
||||||
|
monkeypatch.setenv("FIRMS_DATASETS", "VIIRS_NOAA20_NRT")
|
||||||
|
# fire_sources already imported FIRMS_DATASETS — patch the module attr
|
||||||
|
monkeypatch.setattr("fire_sources.FIRMS_DATASETS", ["VIIRS_NOAA20_NRT"])
|
||||||
|
|
||||||
|
hits = {"n": 0}
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
text = SAMPLE_CSV
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url):
|
||||||
|
hits["n"] += 1
|
||||||
|
return FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr("fire_sources.httpx.AsyncClient", FakeClient)
|
||||||
|
|
||||||
|
async def fake_publish(points):
|
||||||
|
return len(points)
|
||||||
|
|
||||||
|
monkeypatch.setattr("fire_sources.publish_fire_batch", fake_publish)
|
||||||
|
|
||||||
|
assert asyncio.run(ingest_fires()) == 5
|
||||||
|
assert asyncio.run(ingest_fires()) == 5
|
||||||
|
assert hits["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_rss_hits_http_once_within_ttl(monkeypatch):
|
||||||
|
rss_cache.clear()
|
||||||
|
hits = {"n": 0}
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
text = """<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>t</title>
|
||||||
|
<item><title>hello</title><link>http://x.example/1</link>
|
||||||
|
<description>body</description></item></channel></rss>"""
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url):
|
||||||
|
hits["n"] += 1
|
||||||
|
return FakeResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr("sources.httpx.AsyncClient", FakeClient)
|
||||||
|
|
||||||
|
published = []
|
||||||
|
|
||||||
|
async def fake_publish(subject, event):
|
||||||
|
published.append((subject, event))
|
||||||
|
|
||||||
|
monkeypatch.setattr("sources.publish_event", fake_publish)
|
||||||
|
|
||||||
|
assert asyncio.run(ingest_rss_feed("http://feeds.example/rss")) == 1
|
||||||
|
assert asyncio.run(ingest_rss_feed("http://feeds.example/rss")) == 1
|
||||||
|
assert hits["n"] == 1
|
||||||
|
assert published[0][0] == "events.rss"
|
||||||
91
tests/test_ws_manager.py
Normal file
91
tests/test_ws_manager.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Viewport-filtered in-memory pub/sub — no Redis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from ws_manager import ConnectionManager, point_in_bbox
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_in_bbox_inclusive():
|
||||||
|
box = (-80.0, 35.0, -78.0, 36.0)
|
||||||
|
assert point_in_bbox(-79.0, 35.5, box) is True
|
||||||
|
assert point_in_bbox(-80.0, 35.0, box) is True
|
||||||
|
assert point_in_bbox(-77.0, 35.5, box) is False
|
||||||
|
assert point_in_bbox(-79.0, 34.0, box) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_viewport_does_not_get_firehose():
|
||||||
|
mgr = ConnectionManager()
|
||||||
|
q = mgr.register("tailnet-a")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
n = await mgr.publish_point("ais", {"id": "mmsi-1"}, lat=35.5, lon=-79.0)
|
||||||
|
assert n == 0
|
||||||
|
assert q.empty()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fanout_only_to_intersecting_viewports():
|
||||||
|
mgr = ConnectionManager()
|
||||||
|
q_nc = mgr.register("nc")
|
||||||
|
q_sf = mgr.register("sf")
|
||||||
|
mgr.set_viewport("nc", (-80.0, 35.0, -78.0, 36.0))
|
||||||
|
mgr.set_viewport("sf", (-123.0, 37.0, -121.0, 38.0))
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
n = await mgr.publish_point(
|
||||||
|
"ais", {"id": "mmsi-1", "lat": 35.5, "lon": -79.0},
|
||||||
|
lat=35.5, lon=-79.0,
|
||||||
|
)
|
||||||
|
assert n == 1
|
||||||
|
msg = q_nc.get_nowait()
|
||||||
|
assert msg["type"] == "ais"
|
||||||
|
assert msg["payload"]["id"] == "mmsi-1"
|
||||||
|
assert q_sf.empty()
|
||||||
|
|
||||||
|
n2 = await mgr.publish_point(
|
||||||
|
"adsb", {"id": "a1b2c3"}, lat=37.7, lon=-122.4,
|
||||||
|
)
|
||||||
|
assert n2 == 1
|
||||||
|
msg2 = q_sf.get_nowait()
|
||||||
|
assert msg2["type"] == "adsb"
|
||||||
|
assert q_nc.empty()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_stops_fanout():
|
||||||
|
mgr = ConnectionManager()
|
||||||
|
q = mgr.register("gone")
|
||||||
|
mgr.set_viewport("gone", (-180.0, -90.0, 180.0, 90.0))
|
||||||
|
mgr.unregister("gone")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
n = await mgr.publish_point("ais", {"id": "x"}, lat=0.0, lon=0.0)
|
||||||
|
assert n == 0
|
||||||
|
assert q.empty()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_vessel_fans_out_to_intersecting_client(monkeypatch):
|
||||||
|
from live_layers import upsert_vessel, vessel_last_known
|
||||||
|
from ws_manager import manager
|
||||||
|
|
||||||
|
vessel_last_known.clear()
|
||||||
|
manager._queues.clear()
|
||||||
|
manager._viewports.clear()
|
||||||
|
q = manager.register("nc")
|
||||||
|
manager.set_viewport("nc", (-80.0, 35.0, -78.0, 36.0))
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await upsert_vessel({"id": "366123456", "lat": 35.2, "lon": -79.1, "label": "TEST"})
|
||||||
|
msg = q.get_nowait()
|
||||||
|
assert msg["type"] == "ais"
|
||||||
|
assert msg["payload"]["id"] == "366123456"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
manager.unregister("nc")
|
||||||
|
vessel_last_known.clear()
|
||||||
Loading…
Add table
Reference in a new issue