UNPKG

signalk-ais-target-prioritizer

Version:

A SignalK plugin that priorizes AIS targets according to guard and CPA criteria.

1,227 lines (1,070 loc) 44.8 kB
"use strict"; // FIXME rot coming in in radians now var aisUtilsPromise = import("./public/assets/js/ais-utils.mjs"); let toDegrees; const express = require('express'); const expressApp = express(); var _ = require('lodash'); var SSE = require('./sse.js') var sse = new SSE(); const proxy = require("node-tcp-proxy"); //const { clearInterval } = require("timers"); const mdns = require('multicast-dns')(); const METERS_PER_NM = 1852; const KNOTS_PER_M_PER_S = 1.94384; const httpPort = 39151; const enableNmeaOverTcpServer = true; // ios app will not connect without this const nmeaOverTcpServerPort = 39150; // apps look for nmea traffic on 39150. this is not confurable in the apps. so we proxy signalk 10110 to 39150. const proxySourceHostname = '127.0.0.1'; // signalk server address (localhost - same place this plugin is running) const proxySourcePort = 10110; // signalk nmea over tcp port const enableV3 = true; // ios app will keep on reinitializing without this const enableSse = true; // ios app will keep on reinitializing without this const debugSseComms = false; const debugHttpComms = false; var gps = {}; var targets = new Map(); var positions = []; var app; var collisionProfiles; var selfMmsi, selfName, selfCallsign, selfTypeId; var httpServer; var tcpProxyServer; var streamingHeartBeatInterval; var streamingVesselPositionUnderwayInterval; var streamingAnchorWatchControlInterval; var streamingAnchorWatchInterval; var streamingVesselPositionHistoryInterval; var savePositionInterval; var anchorWatchInterval; var refreshInterval; var anchorWatchControl = { setAnchor: 0, alarmRadius: 30, alarmsEnabled: 0, anchorLatitude: 0, anchorLongitude: 0, anchorCorrectedLat: 0, anchorCorrectedLong: 0, usingCorrected: 0, distanceToAnchor: 0, bearingToAnchor: 0, alarmTriggered: 0, anchorPosition: { a: 0, o: 0, t: 0 } }; var saveCollisionProfiles; // save position every 2 seconds when underway. this changes to every 30 seconds when anchored. const savePositionDelayWhenUnderway = 2000; const savePositionDelayWhenAnchored = 30000; var savePositionDelay = savePositionDelayWhenUnderway; // 86,400 seconds per 24 hour day. 86400/2 = 43200. 86400/30 = 2880. // the mobile app is picky about the model number and version numbers // you dont get all functionality unless you provide valid values // serial number does not seem to matter const aisDeviceModel = { connectedDeviceType: 'XB-8000', connectedDeviceUiVersion: '3.04.17316', connectedDeviceTxVersion: '5.20.17443', connectedDeviceTxBbVersion: '1.2.4.0', connectedDeviceSerialNumber: 'KW37001' }; const stateMappingTextToNumeric = { 'motoring': 0, 'anchored': 1, 'not under command': 2, 'restricted manouverability': 3, 'constrained by draft': 4, 'moored': 5, 'aground': 6, 'fishing': 7, 'sailing': 8, 'hazardous material high speed': 9, 'hazardous material wing in ground': 10, 'ais-sart': 14, 'default': 15, } const stateMappingNumericToText = { 0: 'motoring', 1: 'anchored', 2: 'not under command', 3: 'restricted manouverability', 4: 'constrained by draft', 5: 'moored', 6: 'aground', 7: 'fishing', 8: 'sailing', 9: 'hazardous material high speed', 10: 'hazardous material wing in ground', 14: 'ais-sart', 15: 'default', } // setup auto-discovery /* mdns.on('query', function(query) { console.log('********** mdns query',query.questions); if (query.questions[0] && query.questions[0].name === '_vesper-nmea0183._tcp.local') { console.log('got a query packet:', query, '\n'); mdns.respond({ answers: [ { name: '_vesper-nmea0183._tcp.local', type: 'PTR', class: 'IN', ttl: 300, flush: true, data: 'ribbit._vesper-nmea0183._tcp.local' } ], additionals: [ { name: 'ribbit.local', type: 'A', class: 'IN', ttl: 300, flush: true, data: ip.address() // FIXME: the ip6 block below result inthe mobile app // reporting an additional // discovery with ip 0.0.0.0 // },{ // name: 'ribbit.local', // type: 'AAAA', // class: 'IN', // ttl: 300, // flush: true, // data: ip.address('public','ipv6') }, { name: 'ribbit._vesper-nmea0183._tcp.local', type: 'SRV', class: 'IN', ttl: 300, flush: true, data: { port: 39150, weigth: 0, priority: 10, target: 'ribbit.local' } }, { name: 'ribbit._vesper-nmea0183._tcp.local', type: 'TXT', class: 'IN', ttl: 300, flush: true, data: 'nm=ribbit' } ] }); } }); */ function getDeviceModelXml() { return `<?xml version='1.0' encoding='ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <DeviceModel> <connectedDeviceType>${aisDeviceModel.connectedDeviceType}</connectedDeviceType> <connectedDeviceUiVersion>${aisDeviceModel.connectedDeviceUiVersion}</connectedDeviceUiVersion> <connectedDeviceTxVersion>${aisDeviceModel.connectedDeviceTxVersion}</connectedDeviceTxVersion> <connectedDeviceTxBbVersion>${aisDeviceModel.connectedDeviceTxBbVersion}</connectedDeviceTxBbVersion> <connectedDeviceSerialNumber>${aisDeviceModel.connectedDeviceSerialNumber}</connectedDeviceSerialNumber> </DeviceModel> </Watchmate>`; } function getGpsModelXml() { var xml = `<?xml version='1.0' encoding='ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <GPSModel>`; if (gps && gps.isValid) { xml += `<hasGPS>1</hasGPS> <latitudeText>${formatLat(gps.latitude)}</latitudeText> <longitudeText>${formatLon(gps.longitude)}</longitudeText> <COG>${formatCog(gps.cog)}</COG> <SOG>${formatSog(gps.sog)}</SOG> <HDGT>${formatCog(gps.hdg)}</HDGT> <magvar>${formatFixed(toDegrees(gps.magvar, 2))}</magvar> <hasBowPosition>0</hasBowPosition> <sim>stop</sim> `; } else { xml += `<hasGPS>0</hasGPS>`; } xml += `</GPSModel> </Watchmate>`; return xml; } function getGpsModelAdvancedXml() { var xml = `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <GPSModel>`; if (gps && gps.isValid) { xml += `<hasGPS>1</hasGPS> <latitudeText>${formatLat(gps.latitude)}</latitudeText> <longitudeText>${formatLon(gps.longitude)}</longitudeText> <COG>${formatCog(gps.cog)}</COG> <SOG>${formatSog(gps.sog)}</SOG> <HDGT>${formatCog(gps.hdg)}</HDGT> <magvar>${formatFixed(toDegrees(gps.magvar, 2))}</magvar> <hasBowPosition>0</hasBowPosition> <sim>stop</sim> <Fix> <fixType>2</fixType> <AutoMode>1</AutoMode> <HDOP>0.94</HDOP> <PDOP>1.86</PDOP> <VDOP>1.61</VDOP> <metersAccuracy>1.9</metersAccuracy> <fix_ids>2</fix_ids> <fix_ids>5</fix_ids> <fix_ids>6</fix_ids> <fix_ids>9</fix_ids> <fix_ids>12</fix_ids> <fix_ids>17</fix_ids> <fix_ids>19</fix_ids> <fix_ids>23</fix_ids> <fix_ids>25</fix_ids> </Fix> <GPSSatsInView> <SatID>2</SatID> <El>059</El> <Az>296</Az> <SNR>39</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>5</SatID> <El>028</El> <Az>210</Az> <SNR>40</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>6</SatID> <El>059</El> <Az>042</Az> <SNR>46</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>9</SatID> <El>024</El> <Az>079</Az> <SNR>42</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>12</SatID> <El>047</El> <Az>274</Az> <SNR>36</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>17</SatID> <El>029</El> <Az>121</Az> <SNR>38</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>19</SatID> <El>055</El> <Az>111</Az> <SNR>29</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>23</SatID> <El>015</El> <Az>053</Az> <SNR>37</SNR> </GPSSatsInView> <GPSSatsInView> <SatID>25</SatID> <El>023</El> <Az>312</Az> <SNR>33</SNR> </GPSSatsInView>`; } else { xml += `<hasGPS>0</hasGPS>`; } xml += `</GPSModel > </Watchmate > `; return xml; } function getTxStatusModelXml() { return `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <TxStatus> <warnMMSI>0</warnMMSI> <warnSilent>0</warnSilent> <warnStartup>0</warnStartup> <warnGPS>0</warnGPS> <warnPosReportSent>0</warnPosReportSent> <statusVSWR>1</statusVSWR> <valueVSWR>6</valueVSWR> <antennaInUse>0</antennaInUse> <gpsSBAS>0</gpsSBAS> <gpsSmooth>1</gpsSmooth> <gpsFastUpdate>0</gpsFastUpdate> <nmeaInBaud>4800</nmeaInBaud> <nmeaOutBaud>38400</nmeaOutBaud> <nmeaEchoAIS>1</nmeaEchoAIS> <nmeaEchoVDO>1</nmeaEchoVDO> <nmeaEchoGPS>1</nmeaEchoGPS> <nmeaEchoN2K>1</nmeaEchoN2K> <nmeaEchoNMEA>1</nmeaEchoNMEA> <n2kBus>2</n2kBus> <n2kProdCode>9511</n2kProdCode> <n2kAdr>21</n2kAdr> <n2kDevInst>0</n2kDevInst> <n2kSysInst>0</n2kSysInst> <n2kPosRate>500</n2kPosRate> <n2kCogRate>500</n2kCogRate> <externalAlarm>2</externalAlarm> <extSwitchFunc>2</extSwitchFunc> <extSwitchState>0</extSwitchState> <channelStatus> <frequency>161.975</frequency> <mode>1</mode> <rssi>-105</rssi> <rxCount>118</rxCount> <txCount>3</txCount> </channelStatus> <channelStatus> <frequency>162.025</frequency> <mode>1</mode> <rssi>-104</rssi> <rxCount>121</rxCount> <txCount>0</txCount> </channelStatus> </TxStatus> </Watchmate>`; } // anchorLatitude of 399510671 == N 39° 57.0645 // 39.9510671 = 39 deg 57.064026 mins function getAnchorWatchModelXml() { return `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <AnchorWatch> <setAnchor>${anchorWatchControl.setAnchor}</setAnchor> <alarmRadius>${anchorWatchControl.alarmRadius}</alarmRadius> <alarmsEnabled>${anchorWatchControl.alarmsEnabled}</alarmsEnabled> <anchorLatitude>${anchorWatchControl.anchorPosition.a}</anchorLatitude> <anchorLongitude>${anchorWatchControl.anchorPosition.o}</anchorLongitude> <anchorCorrectedLat></anchorCorrectedLat> <anchorCorrectedLong></anchorCorrectedLong> <usingCorrected>0</usingCorrected> <distanceToAnchor>${formatFixed(anchorWatchControl.distanceToAnchor, 1)}</distanceToAnchor> <bearingToAnchor>${anchorWatchControl.bearingToAnchor || ''}</bearingToAnchor> <alarmTriggered>${anchorWatchControl.alarmTriggered}</alarmTriggered> </AnchorWatch> </Watchmate>`; } function getPreferencesXml() { return `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <Prefs> <PrefsRequested> {2, { "accept.demo_mode", ""}, { "profile.current", ""}} </PrefsRequested> <Pref prefname='accept.demo_mode'>0</Pref> <Pref prefname='profile.current'>${collisionProfiles.current.toUpperCase()}</Pref> </Prefs> </Watchmate>`; } function getAlarmsXml() { var response = `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='1'>`; for (var target of targets.values()) { if (target.alarmState) { response += `<Alarm MMSI='${target.mmsi}' state='${translateAlarmState(target.alarmState) || ''}' type='${target.alarmType || ''}'> <Name>${xmlescape(target.name) || ''}</Name> <COG>${formatCog(target.cog)}</COG> <SOG>${formatSog(target.sog)}</SOG> <CPA>${formatCpa(target.cpa)}</CPA> <TCPA>${formatTcpa(target.tcpa)}</TCPA> <Range>${formatCpa(target.range)}</Range> <BearingTrue>${target.bearing || ''}</BearingTrue> <TargetType>${target.vesperTargetType || ''}</TargetType> </Alarm>`; } } response += '</Watchmate>'; return response; } function getSimsXml() { return `<?xml version='1.0' encoding='ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <SimFiles> <simfile>TamakiStrait.sim</simfile> <simfile>TamakiStraitMOB.sim</simfile> <simfile>VirginIslands.sim</simfile> <simfile>AnchorWatch.sim</simfile> </SimFiles> <sim>stop</sim> </Watchmate>` } function getTargetsXml() { var response = `<?xml version='1.0' encoding='ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> `; for (var target of targets.values()) { if (target.isValid && target.mmsi != selfMmsi) { response += `<Target> <MMSI>${target.mmsi}</MMSI> <Name>${xmlescape(target.name) || ''}</Name> <CallSign>${xmlescape(target.callsign) || ''}</CallSign> <VesselTypeString>${target.type || ''}</VesselTypeString> <VesselType>${target.typeId || ''}</VesselType> <TargetType>${target.vesperTargetType || ''}</TargetType> <Order>${target.order || ''}</Order> <TCPA>${formatTcpa(target.tcpa)}</TCPA> <CPA>${formatCpa(target.cpa)}</CPA> <Bearing>${target.bearing || ''}</Bearing> <Range>${formatCpa(target.range)}</Range> <COG2>${formatCog(target.cog)}</COG2> <SOG>${formatSog(target.sog)}</SOG> <DangerState>${translateAlarmState(target.alarmState) || ''}</DangerState> <AlarmType>${target.alarmType || ''}</AlarmType> <FilteredState>${target.filteredState || ''}</FilteredState> </Target> `; } else { //app.debug('getTargetsXml: not sending invalid target', target); } } response += '</Watchmate>'; return response; } // ios app uses this data - LatitudeText + LongitudeText // android app does not - i have no idea where the android app gets lat long. turns out they calculate it using range and bearing. nuts! function getTargetDetailsXml(mmsi) { var response = `<?xml version='1.0' encoding='ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> `; var target = targets.get(mmsi); if (!target || !target.isValid) { app.debug('getTargetDetailsXml: undefined or invalid target:', mmsi, target); } else { // FIXME vessel size renders as: <Dimensions>199m x 32m</Dimensions> // and this works well in the ios app - even letting you chose to display m or ft // but the android app refuses to show anything response += `<Target> <IMO>${target.imo || '0'}</IMO> <COG>${formatCog(target.cog)}</COG> <HDG>${formatCog(target.hdg)}</HDG> <ROT>${formatRot(target.rot)}</ROT> <Altitude>-1</Altitude> <latitudeText>${formatLat(target.latitude)}</latitudeText> <longitudeText>${formatLon(target.longitude)}</longitudeText> <OffPosition>${target.isOffPosition || '0'}</OffPosition> <Virtual>${target.isVirtual || '0'}</Virtual> <Dimensions>${target.length && target.width ? target.length + 'm x ' + target.width + 'm' : '---'}</Dimensions > <Draft>${target.draft ? target.draft + 'm' : '---'}</Draft> <ClassType>${target.aisClass || ''}</ClassType> <Destination>${xmlescape(target.destination) || ''}</Destination> <ETAText></ETAText> <NavStatus>${stateMappingTextToNumeric[target.status] || ''}</NavStatus> <MMSI>${mmsi || ''}</MMSI> <Name>${xmlescape(target.name) || ''}</Name> <CallSign>${xmlescape(target.callsign) || ''}</CallSign> <VesselTypeString>${target.type || ''}</VesselTypeString> <VesselType>${target.typeId || ''}</VesselType> <TargetType>${target.vesperTargetType || ''}</TargetType> <Order>${target.order || ''}</Order> <TCPA>${formatTcpa(target.tcpa)}</TCPA> <CPA>${formatCpa(target.cpa)}</CPA> <Bearing>${target.bearing || ''}</Bearing> <Range>${formatCpa(target.range)}</Range> <COG2>${formatCog(target.cog)}</COG2> <SOG>${formatSog(target.sog)}</SOG> <DangerState>${translateAlarmState(target.alarmState) || ''}</DangerState> <AlarmType>${target.alarmType || ''}</AlarmType> <FilteredState>${target.filteredState || ''}</FilteredState> </Target >`; } response += '</Watchmate>'; return response; } function getOwnStaticDataXml() { return `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> <OwnStaticData> <MMSI>${selfMmsi}</MMSI> <Name>${selfName}</Name> <CallSign>${selfCallsign}</CallSign> <VesselType>${selfTypeId}</VesselType> <VesselSize a='1' b='1' c='1' d='1'/> </OwnStaticData> </Watchmate>`; } function setupSse() { // ******************** SSE STUFF ********************** // streaming protocol used in lieu of xml or json REST calls if (enableSse) { // send heartbeat streamingHeartBeatInterval = setInterval(() => { sendSseMsg("HeartBeat", { "time": new Date().getTime() }); }, 15000); // send VesselPositionUnderway - 15s? streamingVesselPositionUnderwayInterval = setInterval(() => { // 75:VesselPositionUnderway{"a":407106833,"o":-740460408,"cog":0,"sog":0.0,"var":-13,"t":1576639404} // 80:VesselPositionUnderway{"a":380704720,"o":-785886085,"cog":220.28,"sog":0,"var":-9.77,"t":1576873731} // sse.send("75:VesselPositionUnderway{\"a\":407106833,\"o\":-740460408,\"cog\":0,\"sog\":0.0,\"var\":-13,\"t\":1576639404}\n\n"); if (gps && gps.isValid) { var vesselPositionUnderway = { "a": Math.round(gps.latitude * 1e7), "o": Math.round(gps.longitude * 1e7), "cog": toDegrees(gps.cog), "sog": gps.sog * KNOTS_PER_M_PER_S, "var": toDegrees(gps.magvar), "t": gps.lastSeenDate.getTime() }; sendSseMsg("VesselPositionUnderway", vesselPositionUnderway); } }, 500); // send AnchorWatchControl streamingAnchorWatchControlInterval = setInterval(() => { sendSseMsg("AnchorWatchControl", anchorWatchControl); }, 1000); // send AnchorWatch streamingAnchorWatchInterval = setInterval(() => { var anchorWatchJson = { "outOfBounds": (anchorWatchControl.alarmTriggered == 1), // FIXME: should we send "positions" for "anchorPreviousPositions"? "anchorPreviousPositions": positions }; sendSseMsg("AnchorWatch", anchorWatchJson); }, 1000); // send VesselPositionHistory (BIG message) streamingVesselPositionHistoryInterval = setInterval(() => { sendSseMsg("VesselPositionHistory", positions); }, 5000); } function sendSseMsg(name, data) { if (debugSseComms) app.debug('SSE sending ' + name); var json = JSON.stringify(data); sse.send((json.length + 2) + ":" + name + json + "\n\n"); } // ******************** END SSE STUFF ********************** } // save position - keep up to 2880 positions (24 hours at 30 sec cadence) savePositionInterval = setInterval(() => { if (gps && gps.isValid) { positions.unshift({ a: Math.round(gps.latitude * 1e7), o: Math.round(gps.longitude * 1e7), t: gps.lastSeenDate.getTime() }); if (positions.length > 2880) { positions.length = 2880; } } }, savePositionDelay); anchorWatchInterval = setInterval(() => { updateAnchorWatch(); // collisionProfiles.setFromEmulator = Math.floor(new Date().getTime() / 1000); // app.debug('emulator: setFromIndex,setFromEmulator', collisionProfiles.setFromIndex, collisionProfiles.setFromEmulator, collisionProfiles.anchor.guard.range); // app.debug("collisionProfiles.anchor.guard.range - vesper", collisionProfiles.anchor.guard.range); }, 1000); async function updateAnchorWatch() { try { if (!anchorWatchControl.setAnchor || !gps || !gps.isValid) { return; } // in meters anchorWatchControl.distanceToAnchor = getDistanceFromLatLonInMeters( gps.latitude, gps.longitude, anchorWatchControl.anchorPosition.a / 1e7, anchorWatchControl.anchorPosition.o / 1e7); anchorWatchControl.bearingToAnchor = Math.round(getRhumbLineBearing( gps.latitude, gps.longitude, anchorWatchControl.anchorPosition.a / 1e7, anchorWatchControl.anchorPosition.o / 1e7)); anchorWatchControl.alarmTriggered = (anchorWatchControl.distanceToAnchor > anchorWatchControl.alarmRadius) ? 1 : 0; } catch (err) { app.debug('error in updateAnchorWatch', err.message, err, anchorWatchControl, gps); } } function setupHttpServer() { // ======================= HTTP SERVER ======================== // listens to requests from mobile app on port 39151 expressApp.set('x-powered-by', false); expressApp.set('etag', false); // enabling compression breaks SSE - so cant do that //expressApp.use(compression()); // log all requests expressApp.use(function (req, res, next) { if (debugHttpComms) app.debug(`received request ${req.method} ${req.originalUrl} ${JSON.stringify(req.query)} `); next(); }); //}, compression()); expressApp.use(express.json()) // for parsing application/json expressApp.use(express.urlencoded({ extended: true })) // for parsing application/x-www-form-urlencoded // sanity expressApp.get('/', (req, res) => res.send('Hello World!')); // GET /datamodel/getModel?***** expressApp.get('/datamodel/getModel', (req, res) => { //app.debug(req.query); // responding in native xml // https://www.npmjs.com/package/xml //var xml = require('xml'); //response.set('Content-Type', 'text/xml'); //response.send(xml(name_of_restaurants)); // GET /datamodel/getModel?DeviceModel if (req.query.DeviceModel === '') { sendXmlResponse(res, getDeviceModelXml()); } // GET /datamodel/getModel?GPSModel else if (req.query.GPSModel === '') { sendXmlResponse(res, getGpsModelXml()); } // GET /datamodel/getModel?GPSModel.,Advanced // GET /datamodel/getModel?GPSModel.Advanced else if (req.query["GPSModel.,Advanced"] === '' || req.query["GPSModel.Advanced"] === '') { sendXmlResponse(res, getGpsModelAdvancedXml()); } // GET /datamodel/getModel?TxStatus else if (req.query.TxStatus === '') { sendXmlResponse(res, getTxStatusModelXml()); } // GET /datamodel/getModel?AnchorWatch else if (req.query.AnchorWatch === '') { sendXmlResponse(res, getAnchorWatchModelXml()); } // GET /datamodel/getModel?OwnStaticData else if (req.query.OwnStaticData === '') { //app.debug('*** sending getOwnStaticDataXml'); sendXmlResponse(res, getOwnStaticDataXml()); } // 404 else { app.debug('** sending empty response', req.method, req.originalUrl, req.query); res.status(404).end(); /* res.set('Content-Type', 'text/xml'); var xml = `<?xml version = '1.0' encoding = 'ISO-8859-1' ?> <Watchmate version='1.0' priority='0'> </Watchmate>`; res.send(Buffer.from(xml); */ } }); // GET /prefs/start_notifying // FIXME: "Hello" 200 text/html ???? // not sure what this is supposed to do or what the response is supposed to be // would be nice to get a sample from a real xb-8000 expressApp.get('/prefs/start_notifying', (req, res) => { res.json(); }); // GET /prefs/getPreferences?accept.demo_mode&profile.current expressApp.get('/prefs/getPreferences', (req, res) => { sendXmlResponse(res, getPreferencesXml()); }); // GET /alarms/get_current_list expressApp.get('/alarms/get_current_list', (req, res) => { sendXmlResponse(res, getAlarmsXml()); // FIXME: testing response format when there are no alarms. the ios app makes excessive calls, so i dont think we have this quite right yet. //res.json(); //res.status(204).end() // 204 No Content // 404 Not Found }); // GET /test/getSimFiles expressApp.get('/test/getSimFiles', (req, res) => { sendXmlResponse(res, getSimsXml()); }); // GET /targets/getTargets expressApp.get('/targets/getTargets', (req, res) => { sendXmlResponse(res, getTargetsXml()); }); // GET /targets/getTargetDetails?MMSI=255805923 expressApp.get('/targets/getTargetDetails', (req, res) => { var mmsi = req.query.MMSI; if (!mmsi || !/[0-9]{9}/.test(mmsi)) { app.debug('ERROR: /targets/getTargetDetails request with invalid mmsi', req.query, mmsi); } sendXmlResponse(res, getTargetDetailsXml(mmsi)); }); // GET /prefs/setPreferences?profile.current=OFFSHORE expressApp.get('/prefs/setPreferences', (req, res) => { if (req.query["profile.current"]) { collisionProfiles.current = req.query["profile.current"].toLowerCase(); saveCollisionProfiles(); sendXmlResponse(res, getPreferencesXml()); } else { app.debug(`*** sending 404 for ${req.method} ${req.originalUrl}`); res.sendStatus(404); } }); // GET /alarms/mute_alarm expressApp.get('/alarms/mute_alarm', (req, res) => { muteAlarms(); res.json(); }); expressApp.get('/datamodel/propertyEdited', (req, res) => { // GET /datamodel/propertyEdited?AnchorWatch.setAnchor=1 if (req.query["AnchorWatch.setAnchor"]) { var setAnchor = req.query["AnchorWatch.setAnchor"]; if (setAnchor == 1) { setAnchored(); } else { setUnderway(); } } // GET /datamodel/propertyEdited?AnchorWatch.alarmsEnabled=1 if (req.query["AnchorWatch.alarmsEnabled"]) { app.debug('setting anchorWatchControl.alarmsEnabled', req.query["AnchorWatch.alarmsEnabled"]); anchorWatchControl.alarmsEnabled = req.query["AnchorWatch.alarmsEnabled"]; } // GET /datamodel/propertyEdited?AnchorWatch.alarmRadius=38 if (req.query["AnchorWatch.alarmRadius"]) { app.debug('setting anchorWatchControl.alarmRadius', req.query["AnchorWatch.alarmRadius"]); anchorWatchControl.alarmRadius = req.query["AnchorWatch.alarmRadius"]; } res.json(); }); // ******************** ALL V3 RELATED STUFF ************************ // v3 is a streaming model // FIXME: what if we just didnt respond to this v3 stuff? do the apps switch to just regular REST? // ANDROID: imple? sse? // /v3/openChannel y y - // /v3/subscribeChannel?Sensors y-all NO NO // /v3/subscribeChannel?HeartBeat y y 5s // /v3/subscribeChannel?AnchorWatch y y 1s // /v3/subscribeChannel?AnchorWatchControl y y 1s // /v3/subscribeChannel?VesselPositionUnderway y y 0.5s // /v3/subscribeChannel?VesselPositionHistory y y 5s // IOS: // GET /v3/openChannel y y - // GET /v3/subscribeChannel?VesselPositionUnderway y-all y 0.5s // GET /v3/subscribeChannel?MobAlarm y // GET /v3/subscribeChannel?CollisionAlarm y // GET /v3/subscribeChannel?VesselAlarm y // GET /v3/subscribeChannel?AnchorWatch y y 1s // GET /v3/subscribeChannel?AnchorWatchControl y y 1s // GET /v3/subscribeChannel?HeartBeat y // GET /v3/subscribeChannel?VesselPositionHistory y y 5s // GET /v3/watchMate/collisionProfiles y // PUT /v3/anchorwatch/AnchorWatchControl y // GET /v3/tickle?AnchorWatchControl y-all if (enableV3) { // /v3/openChannel // after adding this, we get poounded with GET /v3/subscribeChannel?VesselPositionUnderway expressApp.get('/v3/openChannel', sse.init); // GET /v3/subscribeChannel?<anything> expressApp.get('/v3/subscribeChannel', (req, res) => { res.json(); }); // GET /v3/watchMate/collisionProfiles // JSON.stringify(collisionProfiles,null,2); expressApp.get('/v3/watchMate/collisionProfiles', (req, res) => { res.json(collisionProfiles); }); // PUT /v3/watchMate/collisionProfiles // PUT /v3/watchMate/collisionProfiles { '{"harbor":{"guard":{"range":0.5}}}': '' } // android: // guard, danger, warning // 'content-type': 'application/x-www-form-urlencoded' // ios: // guard, danger, warning... PLUS... threat. threat is same as warning. // 'content-type': 'application/json' expressApp.put('/v3/watchMate/collisionProfiles', (req, res) => { app.debug('PUT /v3/watchMate/collisionProfiles', req.body); //app.debug("before merge", collisionProfiles); mergePutData(req, collisionProfiles); //app.debug("after merge", collisionProfiles); // remove "threat" paths that watchmate adds: delete collisionProfiles.anchor.threat; delete collisionProfiles.harbor.threat; delete collisionProfiles.coastal.threat; delete collisionProfiles.offshore.threat; saveCollisionProfiles(); res.json(); }); // GET /v3/tickle?xxxx // GET /v3/tickle?AnchorWatch // GET /v3/tickle?AnchorWatchControl expressApp.get('/v3/tickle', (req, res) => { //app.debug('req.query', req.query); // GET /v3/tickle?AnchorWatch if (req.query.AnchorWatch !== undefined) { sendSseMsg("VesselPositionHistory", positions); res.json(); } // GET /v3/tickle?AnchorWatchControl else if (req.query.AnchorWatchControl !== undefined) { sendSseMsg("AnchorWatchControl", anchorWatchControl); res.json(); } // OTHER else { app.debug(`*** unexpected tickle ${req.method} ${req.originalUrl} ${req.query} `); res.json(); } }); // PUT /v3/anchorwatch/AnchorWatchControl [object Object] expressApp.put('/v3/anchorwatch/AnchorWatchControl', (req, res) => { app.debug('PUT /v3/anchorwatch/AnchorWatchControl', req.body); mergePutData(req, anchorWatchControl); /* anchorWatchControl.setAnchor = data.setAnchor; anchorWatchControl.alarmsEnabled = data.alarmsEnabled; anchorWatchControl.anchorPosition = data.anchorPosition; */ anchorWatchControl.anchorLatitude = anchorWatchControl.anchorPosition.a; anchorWatchControl.anchorLongitude = anchorWatchControl.anchorPosition.o; app.debug('anchorWatchControl', anchorWatchControl); res.json(); }); } // catchall 404 expressApp.all('*', function (req, res) { app.debug('*** sending empty response', req.method, req.originalUrl, req.query); res.status(404).end(); }); httpServer = expressApp.listen(httpPort, () => app.debug(`HTTP server listening on port ${httpPort}`)) } // ======================= END HTTP SERVER ======================== // ======================= NMEA OVER TCP SERVER ======================== // listens to requests from mobile apps on port 39150 (not configurable in the mobile apps - otherwise we'd just point it to 10110) // forwards nmea0183 messages to mobile apps // the app wants to see traffic on port 39150. if it does not, it will // periodically reinitialize. i guess this is a mechanism to try and restore // what it perceives as lost connectivity with the Vesper AIS unit. function setupTcpProxyServer() { if (enableNmeaOverTcpServer) { tcpProxyServer = proxy.createProxy(nmeaOverTcpServerPort, proxySourceHostname, proxySourcePort); app.debug('Proxy server listening on port ' + nmeaOverTcpServerPort); } } // ======================= END TCP SERVER ======================== function sendXmlResponse(res, xml) { res.set('Content-Type', 'application/xml').send(Buffer.from(xml, 'latin1')); } function mergePutData(req, originalObject) { var contentType = req.header('content-type'); var update = undefined; //app.debug('contentType', contentType); //app.debug('req.body', req.body); if (contentType && contentType === 'application/json') { update = req.body; } else { update = JSON.parse(Object.keys(req.body)[0]) } _.merge(originalObject, update); } function setAnchored() { app.debug('setting anchored'); anchorWatchControl = { setAnchor: 1, alarmRadius: 30, alarmsEnabled: 1, alarmTriggered: 0, anchorLatitude: Math.round(gps.latitude * 1e7), anchorLongitude: Math.round(gps.longitude * 1e7), anchorCorrectedLat: 0, anchorCorrectedLong: 0, usingCorrected: 0, distanceToAnchor: 0, bearingToAnchor: 0, anchorPosition: { a: Math.round(gps.latitude * 1e7), o: Math.round(gps.longitude * 1e7), t: gps.lastSeenDate.getTime() } }; //collisionProfiles.current = "anchor"; //saveCollisionProfiles(); savePositionDelay = savePositionDelayWhenAnchored; } function setUnderway() { app.debug('setting underway'); anchorWatchControl = { setAnchor: 0, alarmRadius: 30, alarmsEnabled: 0, alarmTriggered: 0, anchorLatitude: 0, anchorLongitude: 0, anchorCorrectedLat: 0, anchorCorrectedLong: 0, usingCorrected: 0, distanceToAnchor: 0, bearingToAnchor: 0, anchorPosition: { a: 0, o: 0, t: 0 } }; //collisionProfiles.current = "coastal"; //saveCollisionProfiles(); savePositionDelay = savePositionDelayWhenUnderway; } function muteAlarms() { for (let target of targets.values()) { if (target.alarmState === 'danger') { // FIXME nothing is consuming alarmIsMuted target.alarmIsMuted = true; } } // TODO: or should we just silence the anchor watch for 20 minutes? that // might be better if (anchorWatchControl.alarmsEnabled == 1 && anchorWatchControl.alarmTriggered == 1) { anchorWatchControl.alarmsEnabled = 0; } } function translateAlarmState(alarmState) { return alarmState == "warning" ? "threat" : alarmState; } // latitudeText: 'N 39° 57.0689', function formatLat(dec) { var decAbs = Math.abs(dec); var deg = ('0' + Math.floor(decAbs)).slice(-2); var min = ('0' + ((decAbs - deg) * 60).toFixed(4)).slice(-7); return (dec > 0 ? "N" : "S") + " " + deg + "° " + min; } // longitudeText: 'W 075° 08.3692', function formatLon(dec) { var decAbs = Math.abs(dec); var deg = ('00' + Math.floor(decAbs)).slice(-3); var min = ('0' + ((decAbs - deg) * 60).toFixed(4)).slice(-7); return (dec > 0 ? "E" : "W") + " " + deg + "° " + min; } // return in knots function formatSog(sog) { return formatFixed(sog * KNOTS_PER_M_PER_S, 1); } function formatCog(cog) { return cog === undefined ? '' : ('00' + Math.round(toDegrees(cog))).slice(-3); } function formatRot(rot) { // sample: 3°/min // to decode the field value, divide by 4.733and then square it. Sign of the field value should be preserved return rot === undefined || rot == 0 || rot == -128 ? '' : Math.round(Math.pow(rot / 4.733, 2)) + '°/min'; } function formatCpa(cpa) { return cpa === undefined || cpa === null ? '' : formatFixed(cpa / METERS_PER_NM, 2); } function formatFixed(number, digits) { return number === undefined ? '' : number.toFixed(digits); } function formatTcpa(tcpa) { // returns hh:mm:ss, e.g. 01:15:23 if (tcpa === undefined || tcpa === null || tcpa < 0) { return ''; } // when more than 60 mins, then format hh:mm:ss else if (Math.abs(tcpa) >= 3600) { return (tcpa < 0 ? '-' : '') + new Date(1000 * Math.abs(tcpa)).toISOString().substring(11, 19) } // when less than 60 mins, then format mm:ss else { return (tcpa < 0 ? '-' : '') + new Date(1000 * Math.abs(tcpa)).toISOString().substring(14, 19) } } function xmlescape(string, ignore) { var pattern; var map = { '>': '&gt;', '<': '&lt;', "'": '&apos;', '"': '&quot;', '&': '&amp;' }; if (string === null || string === undefined) return; ignore = (ignore || '').replace(/[^&"<>\']/g, ''); pattern = '([&"<>\'])'.replace(new RegExp('[' + ignore + ']', 'g'), ''); return string.replace(new RegExp(pattern, 'g'), function (str, item) { return map[item]; }) } // derive target data this is only used by the vesper emulator function refreshTargetData() { gps = targets.get(selfMmsi); targets.forEach((target, mmsi) => { // 111MIDXXX SAR (Search and Rescue) aircraft if (mmsi.startsWith('111')) { target.vesperTargetType = 5; } // targetType determines what kind of symbol gets used to represent the target in the vesper mobile app // 970MIDXXX AIS SART (Search and Rescue Transmitter) else if (mmsi.startsWith('970')) { target.vesperTargetType = 6; } // 972XXXXXX MOB (Man Overboard) device else if (mmsi.startsWith('972')) { target.vesperTargetType = 7; } // 974XXXXXX EPIRB (Emergency Position Indicating Radio Beacon) AIS else if (mmsi.startsWith('974')) { target.vesperTargetType = 8; } // Aid to Navigation // 99MIDXXXX Aids to Navigation else if (target.aisClass == 'ATON' || mmsi.startsWith('99')) { target.vesperTargetType = 4; } // class A else if (target.aisClass == 'A') { target.vesperTargetType = 1; } // make evrything else class B else { target.vesperTargetType = 2; } }); } module.exports.setCollisionProfiles = function (_collisionProfiles) { collisionProfiles = _collisionProfiles; } module.exports.start = function (_app, _collisionProfiles, _selfMmsi, _selfName, _selfCallsign, _selfTypeId, _targets, _saveCollisionProfiles) { //console.log('vesper.start received:',_collisionProfiles, _selfMmsi, _selfName, _selfCallsign, _selfTypeId, _gps, _targets, _saveCollisionProfiles) app = _app; collisionProfiles = _collisionProfiles; selfMmsi = _selfMmsi; selfName = _selfName; selfCallsign = _selfCallsign; selfTypeId = _selfTypeId; targets = _targets; saveCollisionProfiles = _saveCollisionProfiles; Promise.resolve(aisUtilsPromise).then(function (aisUtils) { toDegrees = aisUtils.toDegrees; app.debug('starting vesper emulator', collisionProfiles); refreshTargetData(); setupHttpServer(); setupTcpProxyServer(); setupSse(); // update the data model every 1000 ms refreshInterval = setInterval(() => { refreshTargetData(); }, 1000); }) } module.exports.stop = function () { if (streamingHeartBeatInterval) clearInterval(streamingHeartBeatInterval); if (streamingVesselPositionUnderwayInterval) clearInterval(streamingVesselPositionUnderwayInterval); if (streamingAnchorWatchControlInterval) clearInterval(streamingAnchorWatchControlInterval); if (streamingAnchorWatchInterval) clearInterval(streamingAnchorWatchInterval); if (streamingVesselPositionHistoryInterval) clearInterval(streamingVesselPositionHistoryInterval); if (savePositionInterval) clearInterval(savePositionInterval); if (anchorWatchInterval) clearInterval(anchorWatchInterval); if (refreshInterval) clearInterval(refreshInterval); if (tcpProxyServer) { try { app.debug('Stopping proxy server listening on port ' + nmeaOverTcpServerPort); tcpProxyServer.end(); } catch (e) { app.debug(e); } } if (httpServer) { try { app.debug(`Stopping HTTP server listening on port ${httpPort}`) httpServer.close(); } catch (e) { app.debug(e); } } }