Add ADSB Exchange

This commit is contained in:
2024-09-10 21:02:00 -06:00
parent 76897bf48c
commit 7fae07b3c3
7 changed files with 414 additions and 2 deletions

View File

@@ -0,0 +1,116 @@
import {
BadRequestException,
Controller,
Get,
Logger,
Param,
UseInterceptors,
} from '@nestjs/common';
import { ExtendedAircraft, LiteAircraft } from './types';
import { AdsbExchangeService, ValidRadius } from './adsb-exchange.service';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { ApiParam, ApiTags } from '@nestjs/swagger';
@Controller('adsb-exchange')
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@ApiTags('adsb-exchange')
export class AdsbExchangeController {
private readonly logger: Logger = new Logger(AdsbExchangeController.name);
constructor(private readonly adsbExchangeService: AdsbExchangeService) {}
@Get('radius/lat/:lat/long/:long/dist/:dist')
@ApiParam({
name: 'lat',
type: Number,
example: 40.6043,
required: true,
description: 'Latitude of the center point',
})
@ApiParam({
name: 'long',
type: Number,
example: -105.01919,
required: true,
description: 'Longitude of the center point, note that West is negative',
})
@ApiParam({
name: 'dist',
type: Number,
example: 10,
required: true,
description:
'Radius in nautical miles. Must be one of 1, 5, 10, 25, 50, 100, 250',
})
async adsbExchangeAircraftWithinRadius(
@Param('lat') lat: number,
@Param('long') long: number,
@Param('dist') dist: number,
): Promise<ExtendedAircraft[]> {
dist = Number(dist);
lat = Number(lat);
long = Number(long);
this.logger.verbose(
`Requesting aircraft within ${dist} nautical miles of ${lat}, ${long}`,
);
if (!this.adsbExchangeService.isValidRadius(dist)) {
throw new BadRequestException(`Invalid radius: ${dist}`);
}
return this.adsbExchangeService.extendAircraft(
await this.adsbExchangeService.adsbExchangeAircraftWithinRadius(dist, {
lat,
long,
}),
{ lat, long },
);
}
@Get('radius/lat/:lat/long/:long/dist/:dist/lite')
@ApiParam({
name: 'lat',
type: Number,
example: 40.6043,
required: true,
description: 'Latitude of the center point',
})
@ApiParam({
name: 'long',
type: Number,
example: -105.01919,
required: true,
description: 'Longitude of the center point, note that West is negative',
})
@ApiParam({
name: 'dist',
type: Number,
example: 10,
required: true,
description:
'Radius in nautical miles. Must be one of 1, 5, 10, 25, 50, 100, 250',
})
async adsbExchangeAircraftWithinRadiusLite(
@Param('lat') lat: number,
@Param('long') long: number,
@Param('dist') dist: number,
): Promise<LiteAircraft[]> {
dist = Number(dist);
lat = Number(lat);
long = Number(long);
this.logger.verbose(
`Requesting aircraft within ${dist} nautical miles of ${lat}, ${long}`,
);
if (!this.adsbExchangeService.isValidRadius(dist)) {
throw new BadRequestException(`Invalid radius: ${dist}`);
}
const aircraft = await this.adsbExchangeService.extendAircraft(
await this.adsbExchangeService.adsbExchangeAircraftWithinRadius(dist, {
lat,
long,
}),
{ lat, long },
);
return this.adsbExchangeService
.liteAircraft(aircraft)
.filter((aircraft) => aircraft.registration !== undefined);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdsbExchangeService } from './adsb-exchange.service';
import { AdsbExchangeController } from './adsb-exchange.controller';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [CacheModule.register()],
providers: [AdsbExchangeService],
controllers: [AdsbExchangeController],
})
export class AdsbExchangeModule {}

View File

@@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { AdsbExchange, Bearing, ExtendedAircraft } from './types';
import { DDPoint, Haversine, UnitOfDistance } from 'haversine-ts';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
const ValidRadii = [1, 5, 10, 25, 50, 100, 250] as const;
export type ValidRadius = (typeof ValidRadii)[number];
@Injectable()
export class AdsbExchangeService {
private readonly logger: Logger = new Logger(AdsbExchangeService.name);
constructor(private readonly configService: ConfigService) {}
isValidRadius(radius: number): radius is ValidRadius {
return ValidRadii.includes(radius as ValidRadius);
}
bearingToAircraft(
aircraft: AdsbExchange.Aircraft,
location: AdsbExchange.Location,
): Bearing {
const locationPoint = new DDPoint(location.lat, location.long);
const aircraftPoint = new DDPoint(aircraft.lat, aircraft.lon);
const haversine = new Haversine(UnitOfDistance.Mile);
const bearing = haversine.getBearing(locationPoint, aircraftPoint);
return {
bearing: bearing.start,
distance: haversine.getDistance(locationPoint, aircraftPoint),
};
}
async adsbExchangeAircraftWithinRadius(
radius: ValidRadius,
location: AdsbExchange.Location,
): Promise<AdsbExchange.Aircraft[]> {
this.logger.verbose(
`Requesting aircraft within ${radius} nautical miles of ${location.lat}, ${location.long}`,
);
const options = {
method: 'GET',
url: `https://adsbexchange-com1.p.rapidapi.com/v2/lat/${location.lat}/lon/${location.long}/dist/${radius}/`,
headers: {
'x-rapidapi-key': this.configService.get<string>('rapidApiKey'),
'x-rapidapi-host': 'adsbexchange-com1.p.rapidapi.com',
},
};
try {
const response =
await axios.request<AdsbExchange.AdsbExchangeRadiusResponse>(options);
return response.data.ac;
} catch (error) {
this.logger.error(error);
return [];
}
}
extendAircraft(
aircraft: AdsbExchange.Aircraft[],
location: AdsbExchange.Location,
): ExtendedAircraft[] {
return aircraft.map((a) => ({
...a,
bearing: this.bearingToAircraft(a, location),
}));
}
liteAircraft = (aircraft: ExtendedAircraft[]) =>
aircraft.map((a) => ({
flight: a.flight,
registration: a.r,
type: a.t,
altitude: a.alt_baro,
speed: a.gs,
track: a.track,
bearing: a.bearing,
}));
}

189
src/adsb-exchange/types.ts Normal file
View File

@@ -0,0 +1,189 @@
export namespace AdsbExchange {
export const AircraftMessageTypes = [
'adsb_icao',
'adsb_icao_nt',
'adsr_icao',
'tisb_icao',
'adsc',
'mlat',
'other',
'mode_s',
'adsb_other',
'adsr_other',
'tisb_other',
'tisb_trackfile',
] as const;
export type AircraftMessageType = (typeof AircraftMessageTypes)[number];
export interface Location {
lat: number;
long: number;
}
export interface Aircraft {
/**
* The 24-bit ICAO identifier of the aircraft
*/
hex: string;
/**
* Type of underlying message/best source of data
*/
type: AircraftMessageType | string;
/**
* Flight callsign or aircraft registration as 8 characters
*/
flight: string;
/**
* Aircraft registration pulled from database
*/
r: string;
/**
* Aircraft type pulled from database
*/
t: string;
/**
* Barometric altitude in feet or "ground"
*/
alt_baro: number | 'ground';
/**
* Geometric (GNSS/INS) altitude in feet
*/
alt_geom: number;
/**
* Groundspeed in knots
*/
gs: number;
/**
* True track over ground in degrees
*/
track: number;
/**
* Rate of change of geometric altitude in feet per minute
*/
geom_rate: number;
/**
* Mode A squawk code encoded as 4 octal digits
*/
squawk: string;
/**
* Emergency status
*/
emergency: string;
/**
* Emitter category to identify particular aircraft
* or vehicle classes. https://www.adsbexchange.com/emitter-category-ads-b-do-260b-2-2-3-2-5-2/
*/
category: string;
/**
* Aircraft location in decimal degrees
*/
lat: number;
/**
* Aircraft location in decimal degrees
*/
lon: number;
/**
* Navigation integrity category
*/
nic: number;
/**
* Radius of containment in meters
*/
rc: number;
/**
* How long ago in seconds the aircraft was last seen
*/
seen_pos: number;
/**
* ADS-B version
*/
version: number;
/**
* Navigation integrity category for barometric altitude
*/
nic_baro: number;
/**
* Navigation accuracy for position
*/
nac_p: number;
/**
* Navigation accuracy for velocity
*/
nac_v: number;
/**
* Source integrity level
*/
sil: number;
/**
* Interpretation of SIL
*/
sil_type: 'unknown' | 'perhour' | 'persample';
/**
* Geometric vertical accuracy
*/
gva: number;
/**
* System design assurance level
*/
sda: number;
/**
* Flight status alert bit
*/
alert: number;
/**
* Flight status special position indicator
*/
spi: number;
mlat: [];
tisb: [];
/**
* Total number of mode S messages received
* for this aircraft
*/
messages: number;
/**
* How long ago in seconds a message was last received
* for this aircraft
*/
seen: number;
/**
* Recent average signal strength in dbFS
*/
rssi: number;
}
export interface AdsbExchangeRadiusResponse {
ac: Aircraft[];
msg: string;
now: number;
total: number;
ctime: number;
ptime: number;
}
}
export interface Bearing {
/**
* Bearing in degrees
*/
bearing: number;
/**
* Distance in miles
*/
distance: number;
}
export interface LiteAircraft {
flight?: string;
registration?: string;
type: string;
altitude: number | 'ground';
speed: number;
track: number;
bearing: Bearing;
}
export type ExtendedAircraft = AdsbExchange.Aircraft & {
bearing: Bearing;
};

View File

@@ -8,7 +8,7 @@ import { UsersModule } from './users/users.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DomainrproxyModule } from './domainrproxy/domainrproxy.module';
import configuration from './config/configuration';
import { CacheModule } from '@nestjs/cache-manager';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { IrcbotModule } from './ircbot/ircbot.module';
import { OgScraperModule } from './ogscraper/ogscraper.module';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
@@ -25,6 +25,8 @@ import { PowModule } from './pow/pow.module';
import { FocoCoffeeModule } from './fococoffee/fococoffee.module';
import { JobsModule } from './jobs/jobs.module';
import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis';
import { AdsbExchangeModule } from './adsb-exchange/adsb-exchange.module';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
imports: [
@@ -97,8 +99,15 @@ import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis';
PowModule,
FocoCoffeeModule,
JobsModule,
AdsbExchangeModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
],
})
export class AppModule {}