AIS, ADSB, and Range

This commit is contained in:
2026-06-25 15:02:56 -04:00
parent 40b6a5488f
commit a2441fdb37
8 changed files with 372 additions and 37 deletions

View File

@@ -13,7 +13,8 @@ const SHAPES_PATH = resolve(DATA, 'shapes.json');
const history = new Map();
const MAX_HISTORY = 500;
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
const MIL_RANGES_URL = ':8080/db-3.14.1707/ranges.js';
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
@@ -31,23 +32,37 @@ export async function initAircraftDb() {
db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS aircraft (
icao24 TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
)
`);
icao24 TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
`);
// Always refresh military ranges on startup
const { ADSB_URL } = cfg();
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
if (!res.ok) throw new Error(`Failed to fetch military ranges: HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insertRange = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertRanges = db.transaction(ranges => { for (const [s, e] of ranges) insertRange.run(s.toUpperCase(), e.toUpperCase()); });
insertRanges(military);
if (missing) {
const res = await fetch(CSV_URL);
@@ -62,7 +77,7 @@ export async function initAircraftDb() {
@class, @manufacturer, @model, @modes, @operator,
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
)
`);
`);
const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row);
@@ -94,6 +109,13 @@ export async function initAircraftDb() {
}
}
function isIcaoInMilitaryRange(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
const match = db.prepare('SELECT 1 FROM military_ranges WHERE start <= ? AND end >= ? LIMIT 1').get(hex, hex);
return !!match;
}
function wordMatch(text, keyword) {
if (!text) return false;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -110,7 +132,7 @@ export function classifyAircraft(row) {
const ownerFields = [row.owner];
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
if (MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
if (isIcaoInMilitaryRange(row.icao24) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
if (row.owner && !row.operator) return 'private';
@@ -121,7 +143,7 @@ export function classifyAircraft(row) {
export function enrichAircraft(a) {
if (!a.hex) return a;
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
if (!row) return a;
if (!row) return { ...a, type: isIcaoInMilitaryRange(a.hex) ? 'military' : 'unknown' };
return { ...a, ...row, type: classifyAircraft(row) };
}
@@ -148,6 +170,14 @@ export async function getADSB() {
return aircraft.map(enrichAircraft);
}
export async function getAirTrafficHistory(icao) {
export async function getADSBHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getADSBRange() {
const { ADSB_URL } = cfg();
if (!ADSB_URL) return [];
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
const j = await r.json();
return j?.actualRange?.last24h?.points || [];
}

View File

@@ -1,14 +1,57 @@
import {cfg} from './config.mjs';
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
const SENDER_TYPE = {
0: 'Unknown',
1: 'Base Station',
2: 'Class A',
3: 'Class B',
4: 'SAR Aircraft',
5: 'Aid to Navigation',
6: 'Class B CS',
7: 'Sart/Epirb/MOB'
};
function decodeMsgTypes(status_raw) {
const types = [];
for (let i = 0; i < 32; i++) {
if (status_raw & (1 << i)) types.push(i); // was i + 1
}
return types;
}
export async function getAIS() {
const {ADSB_URL} = cfg();
if(!ADSB_URL) return {data: []};
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
const j = await r.json();
const {keys, values} = j;
const {values} = j;
const boats = values.map(row => {
const obj = {type: 'vessel'};
keys.forEach((key, i) => obj[key] = row[i]);
return obj;
const [
mmsi, lat, lon, distance, bearing, rssi, count, drift,
unknown_bool, speed, heading, course, altitude,
destination, eta, dimension, receiver, sources, channels,
msg_type, msg_type2, status_raw, country,
ship_type, callsign, imo, draught,
unknown1, unknown2, unknown3,
name, name2, name3,
last_signal, count2, sender_type, channels2, unknown4, in_range
] = row;
return {
mmsi, type: SENDER_TYPE[sender_type],
lat, lon, altitude,
heading, drift, speed,
distance, bearing, course,
destination, eta,
rssi, count, receiver, sources,
msg_type: decodeMsgTypes(status_raw),
channels: CHANNEL_MAP[channels] ?? channels,
ship_type, imo, draught,
country, callsign, name: name || name2 || name3,
last_signal, in_range
};
});
return boats;

View File

@@ -9,7 +9,7 @@ import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
import {existsSync} from 'fs';
import {getAIS} from './ais.mjs';
import {getADSB, getAirTrafficHistory, getShapes, initAircraftDb} from './adsb.mjs';
import {getADSB, getADSBHistory, getADSBRange, getShapes, initAircraftDb} from './adsb.mjs';
import {getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
@@ -156,8 +156,9 @@ app.get('/api/space', async (req, res) => {
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
app.get('/api/adsb/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)));
app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
// ── DOCS ──────────────────────────────────────────────────────────────────────