UNPKG

msfs-simconnect-api-wrapper

Version:

A convenient SimConnect API for playing with Microsoft Flight Simulator 2020

555 lines (496 loc) 16.1 kB
/** * This extension adds the following special (non-simconnect) variables: * * - ALL_AIRPORTS, for a list of all airports in the game. * - NEARBY_AIRPORTS, for a list of all airports in the "local reality bubble". * - NEARBY_AIRPORTS:NM, for a list of all airports within <NM> nautical miles of the aircraft. * - AIRPORT:ICAO, for getting a specific airport's information, where `ICAO` is the four character ICAO code. * * This extension adds the following special (non-simconnect) events: * * - AIRPORTS_IN_RANGE, triggered when new airports are added to the "local reality bubble". * - AIRPORTS_OUT_OF_RANGE, triggered when airports are removed from the "local reality bubble". */ import fs from "node:fs"; import zlib from "node:zlib"; import { Protocol, open } from "node-simconnect"; import { getDistanceBetweenPoints as dist } from "./utils.js"; import { RUNWAY_SURFACES, RUNWAY_NUMBER, RUNWAY_DESIGNATOR, ILS_TYPES, SIMCONNECT_FACILITY_LIST_TYPE_AIRPORT, } from "./constants.js"; import path from "node:path"; import url from "node:url"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const AIRPORT_DB_LOCATION = path.join(__dirname, `..`, `airport.db.gz`); export const AirportEvents = { AIRPORTS_IN_RANGE: { name: `AirportsInRage`, desc: `Use this event with the on() function to get notified about airports coming into range of our airplane (by being loaded into the sim's "reality bubble").`, }, AIRPORTS_OUT_OF_RANGE: { name: `AirportsOutOfRange`, desc: `Use this event with the on() function to get notified about airports dropping out of range of our airplane (by being loaded into the sim's "reality bubble").`, }, }; const KM_PER_NM = 1.852; const FEET_PER_METERS = 3.28084; const { abs, asin, atan2, sin, cos, tan, PI } = Math; const degrees = (rad) => (rad / PI) * 180; const radians = (deg) => (deg / 180) * PI; const NEARBY_AIRPORTS = `NEARBY AIRPORTS`; const ALL_AIRPORTS = `ALL AIRPORTS`; const AIRPORT = `AIRPORT:`; const SPECIAL_VARS = [ALL_AIRPORTS, NEARBY_AIRPORTS, AIRPORT]; const AIRPORT_TYPE = SIMCONNECT_FACILITY_LIST_TYPE_AIRPORT; const nextId = (function () { const startId = 100; let id = startId; return () => { if (id > 900) { id = startId; } return id++; }; })(); /** * ...docs go here... */ export async function getAirportHandler(api, handle) { const TYPE = SIMCONNECT_FACILITY_LIST_TYPE_AIRPORT; const IN_RANGE = api.nextId(); const OUT_OF_RANGE = api.nextId(); handle.on(`airportList`, ({ requestID: id, airports }) => { if (id === IN_RANGE || id === OUT_OF_RANGE) { const eventName = AirportEvents[ id === IN_RANGE ? `AIRPORTS_IN_RANGE` : `AIRPORTS_OUT_OF_RANGE` ].name; api.eventListeners[eventName]?.handlers.forEach((handle) => handle(airports) ); } }); handle.subscribeToFacilitiesEx1(TYPE, IN_RANGE, OUT_OF_RANGE); return new Promise(async (resolve) => { const finished = () => resolve(handler); const airports = new AirportHandler(api, finished); async function handler(varName) { return airports.get(varName); } handler.supports = function (name) { if (SPECIAL_VARS.includes(name)) return true; if (name.startsWith(NEARBY_AIRPORTS)) return true; if (name.startsWith(AIRPORT)) return true; return false; }; }); } let cachedAirportData = undefined; export function loadAirportDB(location = AIRPORT_DB_LOCATION) { if (cachedAirportData) return cachedAirportData; if (fs.existsSync(location)) { let zipped, json; try { zipped = fs.readFileSync(location); } catch (e) { console.error(`file read error:`, e); } try { json = zlib.gunzipSync(zipped); } catch (e) { console.error(`zlib error:`, e); } try { cachedAirportData = JSON.parse(json); } catch (e) { console.log(`JSON parse error:`, e); } } return cachedAirportData; } /** * ...docs go here... */ export class AirportHandler { constructor(api, finished) { this.api = api; this.init(api.remote, finished); } async init(remote, finished) { // For some reason if we use our existing API handle, we only get 2 of the // 33 pages for "all airports", so in order to get the full list of airports, // the airport handler uses its own, dedicated simconnection. const { handle } = await open(`airport`, Protocol.KittyHawk, remote); this.handle = handle; console.log(`registering facility data`); const list = []; const requestId = nextId(); await new Promise((resolve) => { const handler = (data) => { if (data.requestID === requestId) { const { airports, entryNumber, outOf } = data; list.push(...airports); if (entryNumber >= outOf - 1) { handle.off("airportList", handler); resolve(); } } }; handle.on("airportList", handler); handle.requestFacilitiesList(AIRPORT_TYPE, requestId); }); const airportCount = list.length; // console.log(`MSFS reported ${airportCount} airports`) this.airports = loadAirportDB(AIRPORT_DB_LOCATION); if (this.airports) { handle.close(); if (this.airports.length !== airportCount) { console.warn( `MSFS has ${airportCount} airports, db has ${this.airports.length} airports.` ); console.warn( `You may need to update your version of msfs-simconnect-api-wrapper...` ); } return finished(); } console.log(`No airport database found: building a new one.`); let N = 0; const AHC = 1000; setDetailFields(handle, AHC); function getAirportDetails({ icao }) { return new Promise((resolve) => { const airportData = { icao, runways: [] }; const requestID = nextId(); const processData = ({ userRequestId: id, type, data }) => { if (type === 0) addAirportDetails(data, airportData); if (type === 1) addAirportRunway(data, airportData); }; const processDataEnd = ({ userRequestId: id, ...rest }) => { handle.off("facilityData", processData); handle.off("facilityDataEnd", processDataEnd); postProcessAirport(airportData); resolve(airportData); }; handle.on("facilityData", processData); handle.on("facilityDataEnd", processDataEnd); handle.requestFacilityData(AHC, requestID, icao); }); } this.airports = []; const processAirports = async (list, resolve) => { if (list.length === 0) return resolve(); const percentage = (1 - list.length / N) * 100; console.log(`${percentage.toFixed(2)}%`); const entry = list.shift(); const details = await getAirportDetails(entry); this.airports.push(details); processAirports(list, resolve); }; const LIST_LIMIT = Infinity; await new Promise((resolve) => { const toProcess = list.slice(0, LIST_LIMIT); N = toProcess.length; processAirports(toProcess, resolve); }); handle.close(); const json = JSON.stringify(this.airports, null, 2); const zipped = zlib.gzipSync(Buffer.from(json)); fs.writeFileSync(AIRPORT_DB_LOCATION, zipped); finished(); } /** * * @param {*} varName * @returns */ async get(varName) { const propName = varName.replaceAll(` `, `_`); if (varName === ALL_AIRPORTS) { return { [propName]: this.airports }; } if (varName.startsWith(NEARBY_AIRPORTS)) { return { [propName]: await this.getAllNearbyAirports(varName) }; } if (varName.startsWith(AIRPORT)) { return { [propName]: await this.getAirport(varName) }; } return {}; } /** * * @param {*} varName * @returns */ async getAllNearbyAirports(varName) { const { PLANE_LATITUDE: lat, PLANE_LONGITUDE: long } = await this.api.get( `PLANE_LATITUDE`, `PLANE_LONGITUDE` ); const distanceNM = getVarArg(varName) ?? 200; const KM = distanceNM * KM_PER_NM; const found = []; this.airports.forEach((airport) => { const alat = radians(airport.latitude); const along = radians(airport.longitude); const d = dist(lat, long, alat, along); if (d <= KM) found.push({ ...airport, distance: d / KM_PER_NM }); }); return found.sort((a, b) => a.distance - b.distance); } /** * * @param {*} varName * @returns */ async getAirport(varName) { const icao = getVarArg(varName); return { [varName]: this.airports.find((airport) => airport.icao === icao), }; } } /** * * @param {*} varName * @returns */ function getVarArg(varName) { if (!varName.includes(`:`)) return; return varName.substring(varName.indexOf(`:`) + 1); } /** * * @param {*} handle * @param {*} AHC */ function setDetailFields(handle, AHC) { const add = (name) => handle.addToFacilityDefinition(AHC, name); // airport add(`OPEN AIRPORT`); add(`LATITUDE`); add(`LONGITUDE`); add(`ALTITUDE`); add(`MAGVAR`); add(`NAME`); add(`NAME64`); add(`REGION`); add(`N_RUNWAYS`); { // runways add(`OPEN RUNWAY`); add(`LATITUDE`); add(`LONGITUDE`); add(`ALTITUDE`); add(`HEADING`); add(`LENGTH`); add(`WIDTH`); add(`PATTERN_ALTITUDE`); add(`SLOPE`); add(`TRUE_SLOPE`); add(`SURFACE`); // ILS details add(`PRIMARY_NUMBER`); add(`PRIMARY_DESIGNATOR`); add(`PRIMARY_ILS_TYPE`); add(`PRIMARY_ILS_ICAO`); add(`PRIMARY_ILS_REGION`); add(`SECONDARY_NUMBER`); add(`SECONDARY_DESIGNATOR`); add(`SECONDARY_ILS_TYPE`); add(`SECONDARY_ILS_ICAO`); add(`SECONDARY_ILS_REGION`); add(`CLOSE RUNWAY`); } add("CLOSE AIRPORT"); // Close } /** * * @param {*} data * @param {*} airportData */ function addAirportDetails(data, airportData) { const latitude = data.readFloat64(); const longitude = data.readFloat64(); const altitude = data.readFloat64() * FEET_PER_METERS; const declination = data.readFloat32(); const name = data.readString32(); const name64 = data.readString64(); const region = data.readString8(); const runwayCount = data.readInt32(); Object.assign(airportData, { latitude, longitude, altitude, declination, name, name64, region, runwayCount, }); } /** * * @param {*} data * @param {*} airportData */ function addAirportRunway(data, airportData, airport) { const latitude = data.readFloat64(); const longitude = data.readFloat64(); const altitude = data.readFloat64() * FEET_PER_METERS; const heading = data.readFloat32(); const length = data.readFloat32(); const width = data.readFloat32(); const patternAltitude = data.readFloat32(); const slope = data.readFloat32(); const slopeTrue = data.readFloat32(); const surface = RUNWAY_SURFACES[data.readInt32()]; const primary_number = RUNWAY_NUMBER[data.readInt32()]; const primary_designator = RUNWAY_DESIGNATOR[data.readInt32()]; const primary_ils_type = ILS_TYPES[data.readInt32()]; const primary_ils_icao = data.readString8(); const primary_ils_region = data.readString8(); const secondary_number = RUNWAY_NUMBER[data.readInt32()]; const secondary_designator = RUNWAY_DESIGNATOR[data.readInt32()]; const secondary_ils_type = ILS_TYPES[data.readInt32()]; const secondary_ils_icao = data.readString8(); const secondary_ils_region = data.readString8(); const approach = [ { designation: primary_designator, marking: primary_number, heading: 0, ILS: { type: primary_ils_type, icao: primary_ils_icao, region: primary_ils_region, }, }, { designation: secondary_designator, marking: secondary_number, heading: 0, ILS: { type: secondary_ils_type, icao: secondary_ils_icao, region: secondary_ils_region, }, }, ]; airportData.runways.push({ latitude, longitude, altitude, heading, length, width, patternAltitude, slope, slopeTrue, surface, approach, }); } // Improve the airport information function postProcessAirport(airport) { // abstract our runways airport.runways.forEach((runway) => setRunwayBounds(runway)); // set the approach headings in true degrees, rather than using the runway markings airport.runways.forEach((runway) => setRunwayApproachHeadings(runway)); } function setRunwayApproachHeadings(runway) { const { start, end } = runway; runway.approach[0].heading = getHeadingFromTo(...end, ...start); runway.approach[1].heading = getHeadingFromTo(...start, ...end); } // by including runway start, end, and bounding box information function setRunwayBounds(runway) { const { altitude, latitude: lat, longitude: long, length, width, heading, slopeTrue, } = runway; let args; // Find the runway endpoints. length is in meters, getPointAtDistance // needs kilometers, so we divide by 1000, and then each end is half // the length from the center. args = [lat, long, length / 2000, heading]; const { lat: latS, long: longS } = getPointAtDistance(...args); args = [lat, long, length / 2000, heading + 180]; const { lat: latE, long: longE } = getPointAtDistance(...args); // Runway start/end coordinates const start = (runway.start = [latS, longS]); const end = (runway.end = [latE, longE]); // Runway start/end altitudes start[2] = altitude; const run = length * 3.28084; const rise = tan(radians(slopeTrue)) * run; end[2] = altitude + rise; // Do we need to swap these? The runway heading indicates // the heading for approaches, so the heading from start // to end should be the opposite heading. const lineHeading = getHeadingFromTo(...start, ...end); const diff = getCompassDiff(lineHeading, heading); if (abs(diff) < 90) { runway.start = end; runway.end = start; } // Runway bbox args = [latS, longS, width / 2000, heading + 90]; const { lat: lat1, long: long1 } = getPointAtDistance(...args); args = [latS, longS, width / 2000, heading - 90]; const { lat: lat2, long: long2 } = getPointAtDistance(...args); args = [latE, longE, width / 2000, heading - 90]; const { lat: lat3, long: long3 } = getPointAtDistance(...args); args = [latE, longE, width / 2000, heading + 90]; const { lat: lat4, long: long4 } = getPointAtDistance(...args); runway.bbox = [ [lat1, long1], [lat2, long2], [lat3, long3], [lat4, long4], ]; } function getPointAtDistance(lat1, long1, d, heading, R = 6371) { ` lat: initial latitude, in degrees lon: initial longitude, in degrees d: target distance from initial in kilometers heading: (true) heading in degrees R: optional radius of sphere, defaults to mean radius of earth Returns new lat/lon coordinate {d}km from initial, in degrees `; lat1 = radians(lat1); long1 = radians(long1); const a = radians(heading); const lat2 = asin(sin(lat1) * cos(d / R) + cos(lat1) * sin(d / R) * cos(a)); const dx = cos(d / R) - sin(lat1) * sin(lat2); const dy = sin(a) * sin(d / R) * cos(lat1); const long2 = long1 + atan2(dy, dx); return { lat: degrees(lat2), long: degrees(long2) }; } export function getHeadingFromTo(lat1, long1, lat2, long2, declination = 0) { lat1 = radians(parseFloat(lat1)); long1 = radians(parseFloat(long1)); lat2 = radians(parseFloat(lat2)); long2 = radians(parseFloat(long2)); const dLon = long2 - long1; const x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon); const y = cos(lat2) * sin(dLon); return (degrees(atan2(y, x)) - declination + 360) % 360; } function getCompassDiff(current, target, direction = 1) { const diff = current > 180 ? current - 360 : current; target = target - diff; const result = target < 180 ? target : target - 360; if (direction > 0) return result; return target < 180 ? 360 - target : target - 360; }