diff --git a/package.json b/package.json index 0e9870a..4461499 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "cache-manager": "^5.3.1", "cache-manager-redis-yet": "^4.1.2", "fp-ts": "^2.16.3", + "haversine-ts": "^1.2.0", "hbs": "^4.2.0", "ioredis": "^5.3.2", "minio": "^7.1.3", diff --git a/src/adsb-exchange/adsb-exchange.controller.ts b/src/adsb-exchange/adsb-exchange.controller.ts new file mode 100644 index 0000000..19558f9 --- /dev/null +++ b/src/adsb-exchange/adsb-exchange.controller.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/adsb-exchange/adsb-exchange.module.ts b/src/adsb-exchange/adsb-exchange.module.ts new file mode 100644 index 0000000..b820240 --- /dev/null +++ b/src/adsb-exchange/adsb-exchange.module.ts @@ -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 {} diff --git a/src/adsb-exchange/adsb-exchange.service.ts b/src/adsb-exchange/adsb-exchange.service.ts new file mode 100644 index 0000000..ca3c8cd --- /dev/null +++ b/src/adsb-exchange/adsb-exchange.service.ts @@ -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 { + 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('rapidApiKey'), + 'x-rapidapi-host': 'adsbexchange-com1.p.rapidapi.com', + }, + }; + + try { + const response = + await axios.request(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, + })); +} diff --git a/src/adsb-exchange/types.ts b/src/adsb-exchange/types.ts new file mode 100644 index 0000000..4d1c088 --- /dev/null +++ b/src/adsb-exchange/types.ts @@ -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; +}; diff --git a/src/app.module.ts b/src/app.module.ts index 4814436..e2ece12 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/yarn.lock b/yarn.lock index 89c8e0f..0e569e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3326,6 +3326,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +haversine-ts@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/haversine-ts/-/haversine-ts-1.2.0.tgz#4a9f3c7e4327abaf2c099d4aa7b35e909d1025cb" + integrity sha512-30SeQ+x9kbplPy5+4o2Sh8zc2eHzOepDbXnDLvkaLXmUT8SknAEAh/fqOqdpTDrH4SmUKFk1VBjgEyWNxBod/w== + hbs@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/hbs/-/hbs-4.2.0.tgz#10e40dcc24d5be7342df9636316896617542a32b"