The Poller and Detection Engine
Objectives
By the end of this chapter, you should be able to:
- Fetch live aircraft data from adsb.lol and store it on a fixed interval
- Understand edge-triggered detection: why “alert once per transition” instead of “alert every cycle”
- Detect emergency squawks and “aircraft near home” overflights
π‘ Why this matters: By the end of this lesson the poller runs standalone, writing to Postgres on its own β no one is watching yet. The next lesson adds the push layer that makes it live.
Why Edge-Triggered Detection
An aircraft squawking 7700 (general emergency) stays on that squawk for the whole incident β it might be visible for ten poll cycles in a row. If detection just checked “is anyone squawking an emergency code right now” every cycle, you’d get ten alert rows and ten notifications for one event. What you actually want is one alert the moment it starts. That’s edge-triggered detection: keep small in-memory state (which hexes are currently alerting) and only emit a detection when a hex transitions from not-alerting to alerting. The same shape solves “aircraft entered my overflight radius” β you want one log entry when it enters, not one every 15 seconds while it’s still inside.
This state lives in plain module-level Map/Set objects, not the database. That’s a deliberate scope decision: the poller is a single long-running process, so in-memory state that resets on restart is fine (worst case after a restart: a currently-emergency-squawking aircraft that was already mid-incident re-alerts once more β an acceptable false-once rather than an unbounded number of false-repeats). If this app needed to run the poller as multiple horizontally-scaled instances, this state would have to move to something shared (Redis, or the database itself) β worth knowing as a limitation, not worth solving until the app actually needs to scale that way.
Shared Types and Helpers
// packages/shared/src/aircraft.ts
export interface AircraftState {
hex: string;
flight?: string | null;
r?: string | null; // registration
t?: string | null; // ICAO type designator, e.g. "B738"
alt_baro?: number | "ground" | null;
alt_geom?: number | null;
gs?: number | null; // ground speed, knots
baro_rate?: number | null; // vertical rate, fpm
geom_rate?: number | null;
track?: number | null;
true_heading?: number | null;
mag_heading?: number | null;
squawk?: string | null;
category?: string | null;
lat?: number | null;
lon?: number | null;
}
export interface AdsbPointResponse {
ac?: AircraftState[];
total?: number;
now?: number;
}
export const EMERGENCY_SQUAWKS = new Set(["7500", "7600", "7700"]);
export function isEmergencySquawk(squawk?: string | null): boolean {
return !!squawk && EMERGENCY_SQUAWKS.has(squawk);
}
/** Normalizes alt_baro (which can be the literal string "ground") to a number or null, in feet. */
export function altitudeFt(a: Pick<AircraftState, "alt_baro" | "alt_geom">): number | null {
if (a.alt_baro === "ground") return null;
if (typeof a.alt_baro === "number") return a.alt_baro;
return a.alt_geom ?? null;
}
export function isOnGround(a: Pick<AircraftState, "alt_baro">): boolean {
return a.alt_baro === "ground";
}
Modeling alt_baro: number | "ground" | null instead of coercing it earlier keeps the actual upstream shape visible in the type β adsb.lol really does send the literal string "ground" in place of an altitude, and altitudeFt()/isOnGround() are the one place that quirk gets normalized, rather than every call site needing to remember it.
// packages/shared/src/geo.ts
export function distanceNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R_NM = 3440.065;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R_NM * c;
}
(bearingDeg and viewRadiusNm join this file in later lessons β add just distanceNm for now.)
// packages/shared/src/location.ts
export interface HomeLocationSettings {
lat: number;
lon: number;
radiusNm: number;
altitudeCeilingFt: number;
updatedAt: string;
}
Re-export all three new files from packages/shared/src/index.ts.
Fetching adsb.lol
Some upstream aviation APIs reject requests with no browser-like User-Agent. Isolate that (and general fetch error handling) once, since it’s reused by the poller and by an on-demand REST proxy in a later module:
// apps/server/src/lib/http.ts
export const UPSTREAM_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
export class UpstreamError extends Error {
constructor(message: string, public readonly status?: number) {
super(message);
this.name = "UpstreamError";
}
}
export async function fetchJson<T>(url: string, timeoutMs = 10_000): Promise<T> {
let resp: Response;
try {
resp = await fetch(url, {
headers: { "User-Agent": UPSTREAM_USER_AGENT, Accept: "application/json" },
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new UpstreamError(`fetch failed: ${message}`);
}
if (!resp.ok) throw new UpstreamError(`upstream returned HTTP ${resp.status}`, resp.status);
return (await resp.json()) as T;
}
// apps/server/src/poller/adsbClient.ts
import type { AdsbPointResponse } from "@skywatch/shared";
import { fetchJson } from "../lib/http.js";
const ADSB_POINT_BASE = "https://api.adsb.lol/v2/point";
export async function fetchPoint(lat: number, lon: number, radiusNm: number): Promise<AdsbPointResponse> {
const clampedRadius = Math.max(1, Math.min(250, Math.round(radiusNm)));
const url = `${ADSB_POINT_BASE}/${lat}/${lon}/${clampedRadius}`;
return fetchJson<AdsbPointResponse>(url);
}
A dedicated UpstreamError class (rather than letting a generic fetch error propagate) matters once the poller is running unattended: it lets loop.ts (below) log “upstream returned HTTP 403” distinctly from “something in our own code threw” β one is adsb.lol having a bad day, the other is a bug you need to look at.
Detection
// apps/server/src/poller/detect.ts
import {
altitudeFt, distanceNm, isEmergencySquawk, isOnGround,
type AircraftState, type HomeLocationSettings,
} from "@skywatch/shared";
export interface SquawkDetection {
hex: string;
flight: string | null;
squawk: string;
lat: number | null;
lon: number | null;
altBaroFt: number | null;
}
const activeSquawkAlerts = new Map<string, string>(); // hex -> currently-alerting squawk code
export function detectSquawkTransitions(aircraft: AircraftState[]): SquawkDetection[] {
const detections: SquawkDetection[] = [];
const seenHexes = new Set<string>();
for (const a of aircraft) {
seenHexes.add(a.hex);
const squawk = a.squawk ?? null;
const wasAlerting = activeSquawkAlerts.get(a.hex);
if (isEmergencySquawk(squawk)) {
if (wasAlerting !== squawk) {
detections.push({
hex: a.hex,
flight: a.flight?.trim() || null,
squawk: squawk as string,
lat: a.lat ?? null,
lon: a.lon ?? null,
altBaroFt: altitudeFt(a),
});
}
activeSquawkAlerts.set(a.hex, squawk as string);
} else if (wasAlerting) {
activeSquawkAlerts.delete(a.hex);
}
}
// An aircraft that drops off adsb.lol entirely (out of range, transponder
// off) never gets a "not alerting anymore" cycle above -- clean it up here
// so a hex that vanishes mid-emergency doesn't leak in this map forever.
for (const hex of activeSquawkAlerts.keys()) {
if (!seenHexes.has(hex)) activeSquawkAlerts.delete(hex);
}
return detections;
}
export interface OverflightDetection {
hex: string;
flight: string | null;
lat: number;
lon: number;
altBaroFt: number | null;
distanceNm: number;
}
const currentlyOverflying = new Set<string>();
export function detectOverflightTransitions(
aircraft: AircraftState[],
home: HomeLocationSettings
): OverflightDetection[] {
const detections: OverflightDetection[] = [];
const seenHexes = new Set<string>();
for (const a of aircraft) {
if (a.lat == null || a.lon == null) continue;
seenHexes.add(a.hex);
const alt = altitudeFt(a);
const dist = distanceNm(home.lat, home.lon, a.lat, a.lon);
const isOverflying =
!isOnGround(a) && dist <= home.radiusNm && alt != null && alt <= home.altitudeCeilingFt;
const wasOverflying = currentlyOverflying.has(a.hex);
if (isOverflying && !wasOverflying) {
detections.push({ hex: a.hex, flight: a.flight?.trim() || null, lat: a.lat, lon: a.lon, altBaroFt: alt, distanceNm: dist });
currentlyOverflying.add(a.hex);
} else if (!isOverflying && wasOverflying) {
currentlyOverflying.delete(a.hex);
}
}
for (const hex of currentlyOverflying) {
if (!seenHexes.has(hex)) currentlyOverflying.delete(hex);
}
return detections;
}
Both functions are structurally identical on purpose: seed a set/map of “currently in the alerting state,” walk this cycle’s aircraft, emit a detection only on the falseβtrue transition, and sweep hexes that vanished without a transition. Once you’ve written one, the second is closer to filling in a template than solving a new problem β which is itself worth noticing as a pattern for future features: when a new requirement rhymes with something you already built, look for the shared shape before writing something unrelated from scratch.
Storage
// apps/server/src/db/repos/homeLocationRepo.ts
import { eq } from "drizzle-orm";
import type { HomeLocationSettings } from "@skywatch/shared";
import { db } from "../client.js";
import { homeLocation } from "../schema.js";
/** Returns null until the user has configured a home location via the UI/API. */
export async function getHomeLocation(): Promise<HomeLocationSettings | null> {
const rows = await db.select().from(homeLocation).where(eq(homeLocation.id, 1));
const row = rows[0];
if (!row) return null;
return { lat: row.lat, lon: row.lon, radiusNm: row.radiusNm, altitudeCeilingFt: row.altitudeCeilingFt, updatedAt: row.updatedAt.toISOString() };
}
(A setHomeLocation counterpart, and the REST route wrapping both, come in a later lesson alongside the alert UX that actually needs an editable home location β for now, insert a row by hand with psql to exercise overflight detection.)
// apps/server/src/poller/store.ts
import type { AircraftState } from "@skywatch/shared";
import { altitudeFt, isOnGround } from "@skywatch/shared";
import { db } from "../db/client.js";
import { overflightLog, positionSnapshots, squawkAlerts } from "../db/schema.js";
import type { OverflightDetection, SquawkDetection } from "./detect.js";
const BATCH_SIZE = 500;
export async function storeSnapshots(aircraft: AircraftState[]): Promise<number> {
const withPosition = aircraft.filter(
(a): a is AircraftState & { lat: number; lon: number } => a.lat != null && a.lon != null
);
if (withPosition.length === 0) return 0;
const rows = withPosition.map((a) => ({
hex: a.hex,
flight: a.flight?.trim() || null,
registration: a.r ?? null,
typeCode: a.t ?? null,
lat: a.lat,
lon: a.lon,
altBaroFt: altitudeFt(a),
onGround: isOnGround(a),
groundSpeedKt: a.gs ?? null,
trackDeg: a.track ?? a.true_heading ?? a.mag_heading ?? null,
verticalRateFpm: a.baro_rate ?? a.geom_rate ?? null,
squawk: a.squawk ?? null,
category: a.category ?? null,
}));
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
await db.insert(positionSnapshots).values(rows.slice(i, i + BATCH_SIZE));
}
return rows.length;
}
export async function storeSquawkAlerts(detections: SquawkDetection[]) {
for (const d of detections) {
await db.insert(squawkAlerts).values({ hex: d.hex, flight: d.flight, squawk: d.squawk, lat: d.lat, lon: d.lon, altBaroFt: d.altBaroFt });
}
}
export async function storeOverflights(detections: OverflightDetection[]) {
for (const d of detections) {
await db.insert(overflightLog).values({ hex: d.hex, flight: d.flight, lat: d.lat, lon: d.lon, altBaroFt: d.altBaroFt, distanceNm: d.distanceNm });
}
}
The BATCH_SIZE = 500 chunking in storeSnapshots is there because a single INSERT statement with thousands of value tuples (a large multi-region fetch across several regions in one cycle adds up) is both a large single query and, more importantly, past Postgres’s own parameter-count limits if you’re not careful. Chunking is cheap insurance that costs nothing at the actual scale this app runs at.
The Loop Itself
// apps/server/src/poller/watchRegions.ts
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { watchRegions } from "../db/schema.js";
const DEFAULT_REGION = { name: "New York (JFK area) β default", lat: 40.6413, lon: -73.7781, radiusNm: 200 };
/** Seeds a default region on first run so the app shows live traffic out of the box. */
export async function ensureDefaultWatchRegion(): Promise<void> {
const existing = await db.select().from(watchRegions);
if (existing.length === 0) {
await db.insert(watchRegions).values(DEFAULT_REGION);
console.log(`[poller] no watch regions configured β created default: "${DEFAULT_REGION.name}"`);
}
}
export async function getEnabledWatchRegions() {
return db.select().from(watchRegions).where(eq(watchRegions.enabled, true));
}
// apps/server/src/poller/loop.ts
import { env } from "../env.js";
import { UpstreamError } from "../lib/http.js";
import { getHomeLocation } from "../db/repos/homeLocationRepo.js";
import { fetchPoint } from "./adsbClient.js";
import { detectOverflightTransitions, detectSquawkTransitions } from "./detect.js";
import { storeOverflights, storeSnapshots, storeSquawkAlerts } from "./store.js";
import { ensureDefaultWatchRegion, getEnabledWatchRegions } from "./watchRegions.js";
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollInFlight = false;
async function pollOnce(): Promise<void> {
if (pollInFlight) {
console.warn("[poller] previous cycle still running, skipping this tick");
return;
}
pollInFlight = true;
try {
const [regions, home] = await Promise.all([getEnabledWatchRegions(), getHomeLocation()]);
if (regions.length === 0) {
console.warn("[poller] no enabled watch regions β nothing to poll");
return;
}
await Promise.all(
regions.map(async (region) => {
try {
const data = await fetchPoint(region.lat, region.lon, region.radiusNm);
const aircraft = data.ac ?? [];
const stored = await storeSnapshots(aircraft);
const squawkDetections = detectSquawkTransitions(aircraft);
if (squawkDetections.length > 0) await storeSquawkAlerts(squawkDetections);
if (home) {
const overflightDetections = detectOverflightTransitions(aircraft, home);
if (overflightDetections.length > 0) await storeOverflights(overflightDetections);
}
console.log(`[poller] "${region.name}": ${aircraft.length} aircraft, ${stored} positions stored`);
} catch (err) {
if (err instanceof UpstreamError) {
console.error(`[poller] region "${region.name}" fetch failed: ${err.message}`);
} else {
console.error(`[poller] region "${region.name}" unexpected error:`, err);
}
}
})
);
} finally {
pollInFlight = false;
}
}
export async function startPoller(): Promise<void> {
await ensureDefaultWatchRegion();
await pollOnce();
pollTimer = setInterval(() => {
pollOnce().catch((err) => console.error("[poller] unhandled error in poll cycle:", err));
}, env.POLL_INTERVAL_MS);
console.log(`[poller] started, polling every ${env.POLL_INTERVAL_MS}ms`);
}
export function stopPoller(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
Two details worth calling out. First, pollInFlight β a re-entrancy guard. setInterval doesn’t wait for the previous callback to finish, so if a poll cycle ever takes longer than POLL_INTERVAL_MS (a slow upstream response, a slow DB write under load), you’d otherwise get overlapping cycles racing each other against the same in-memory detection state. Skipping the tick and logging it is a better failure mode than silent overlap. Second, regions.map(async (region) => ...) with Promise.all and a per-region try/catch β one watch region’s upstream fetch failing shouldn’t take down every other region’s cycle. This is the same “isolate the failure to its blast radius” instinct as the UpstreamError class above, applied one level up.
Wire it into index.ts:
// apps/server/src/index.ts (add)
import { startPoller, stopPoller } from "./poller/loop.js";
// ...
await startPoller();
// in shutdown():
stopPoller();
Try It
- Start the server and watch the logs β you should see
[poller] "New York (JFK area) β default": N aircraft, N positions storedevery 15 seconds (the default region auto-seeds on first run). - Confirm rows are landing:
Run it twice, a few seconds apart, and confirm the count grows.
psql $DATABASE_URL -c "SELECT count(*) FROM position_snapshots;" - To exercise overflight detection without waiting for a real event, insert a home location by hand centered near your watch region with a generous radius:
INSERT INTO home_location (id, lat, lon, radius_nm, altitude_ceiling_ft, updated_at) VALUES (1, 40.6413, -73.7781, 50, 20000, now()); - Watch
overflight_logpick up rows on the next cycle an aircraft is inside that radius and below the ceiling:psql $DATABASE_URL -c "SELECT * FROM overflight_log ORDER BY id DESC LIMIT 5;"
Nothing is pushed to a browser yet β that’s next.
Recap
- Edge-triggered detection emits one detection on the falseβtrue transition of an in-memory
Map/Set, not once per poll cycle for as long as the condition holds β the squawk and overflight detectors are structurally the same function shape applied to two different conditions. - Detection state lives in plain in-memory objects on the poller process. That’s fine for a single long-running process; scaling the poller to multiple instances would mean moving that state somewhere shared, like Redis or the database itself.
pollInFlightguards against overlapping cycles if a poll ever takes longer thanPOLL_INTERVAL_MS; a per-region try/catch means one region’s failed upstream fetch doesn’t take down every other region’s cycle in the same tick.storeSnapshotsbatches inserts in chunks of 500 rows to stay safely under Postgres’s parameter-count limits on a singleINSERT.
Next lesson: pushing all of this to the browser in real time β the WebSocket layer and Postgres LISTEN/NOTIFY.