UNPKG

@yachteye/signalk-vessel-tracker-plugin

Version:
528 lines (527 loc) 25.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); /** Asset Type: tender or fleet. */ var AssetType; (function (AssetType) { AssetType["Tender"] = "tender"; AssetType["Fleet"] = "fleet"; })(AssetType || (AssetType = {})); var AssetSource; (function (AssetSource) { AssetSource["AIS"] = "AIS"; /** Future support. */ AssetSource["SeeTrac"] = "SeeTrac"; })(AssetSource || (AssetSource = {})); /** Status of a tracked vessel. */ var Status; (function (Status) { Status["Unknown"] = "unknown"; Status["OnBoard"] = "on-board"; Status["InRange"] = "in-range"; Status["OutOfRange"] = "out-of-range"; })(Status || (Status = {})); /** * YachtEye Vessel tracker Plugin. * @param {*} app The SignalK app. * @returns The plugin object. */ module.exports = (app) => { const plugin = { id: 'signalk-vessel-tracker-plugin', name: 'YachtEye vessel tracker plugin', restartPlugin: null, intervalTimer: null, unsubscribes: [], vesselsToPoll: [], outOfRange: [], inRange: [], onBoard: [], /** * Start the plugin. * @param settings the configuration data entered via the Plugin Config screen. * @param restartPlugin a function that can be called by the plugin to restart itself. */ start: (settings, restartPlugin) => { app.debug(`starting...`); plugin.restartPlugin = restartPlugin; try { settings.vesselList.forEach((asset) => { switch (asset.assetType) { case 'fleet': break; case 'tender': break; default: app.setPluginError(`Unsupported asset type: '${asset.assetType}'`); break; } switch (asset.source) { case 'AIS': plugin.vesselsToPoll.push(Object.assign(Object.assign({}, asset), { context: `vessels.urn:mrn:imo:mmsi:${asset.id}` })); break; default: app.setPluginError(`Unsupported vessel source: '${asset.source}'`); break; } }); plugin.intervalTimer = setInterval(plugin.pollData, settings.interval * 1000, settings); app.handleMessage(plugin.id, plugin.meta()); for (const vessel of plugin.vesselsToPoll) { plugin.clearVessel(vessel); } app.setPluginStatus(`Started, interval=${settings.interval} (s), ${settings.vesselList.length} vessels configured, ${plugin.vesselsToPoll.length} vessels will be tracked.`); } catch (err) { app.setPluginError(`Error in start(): ${err === null || err === void 0 ? void 0 : err.message}`); } }, /** * Stop the plugin. */ stop: () => { if (plugin.intervalTimer !== null) { clearInterval(plugin.intervalTimer); plugin.intervalTimer = null; } plugin.unsubscribes.forEach(f => f()); plugin.unsubscribes = []; plugin.vesselsToPoll = []; plugin.outOfRange = []; plugin.inRange = []; plugin.onBoard = []; app.debug(`Stopped.`); app.setPluginStatus(`${plugin.name} stopped.`); }, pollData: (settings) => { app.debug('pollData()'); const ONBOARD_RANGE = 250; const outOfRange = []; const inRange = []; const onBoard = []; const now = Date.now(); const yachtPosition = app.getSelfPath('navigation.position'); plugin.vesselsToPoll.forEach(vessel => { const previousStatus = plugin.onBoard.includes(vessel.id) ? Status.OnBoard : plugin.inRange.includes(vessel.id) ? Status.InRange : plugin.outOfRange.includes(vessel.id) ? Status.OutOfRange : Status.Unknown; const position = app.getPath(vessel.context + '.navigation.position'); const speed = app.getPath(vessel.context + '.navigation.speedOverGround'); const heading = app.getPath(vessel.context + '.navigation.courseOverGroundTrue'); if (yachtPosition !== undefined && yachtPosition.value && position !== undefined && position.value) { // app.debug(`MMSI=${vessel.mmsi} '${vessel.context}': position=${position.value}, speed=${speed?.value}, heading=${heading?.value}`); const distance = distanceBetweenMeters(yachtPosition.value.latitude, yachtPosition.value.longitude, position.value.latitude, position.value.longitude); const bearingFromSelf = bearingTo(yachtPosition.value.latitude, yachtPosition.value.longitude, position.value.latitude, position.value.longitude); const timeStamp = new Date(position.timestamp).getTime(); const ageMinutes = now > timeStamp ? (now - timeStamp) / 1000 / 60 : 0; let status = Status.Unknown; app.debug(`MMSI=${vessel.id} '${vessel.assetType}', prev-status=${previousStatus}, lastRange=${vessel.lastRange}(m), distance=${distance.toFixed(1)}, bearingFromSelf=${bearingFromSelf.toFixed(1)}, age=${ageMinutes.toFixed(1)}(min)`); if (ageMinutes <= settings.maxAge) { status = Status.InRange; if (vessel.assetType === AssetType.Tender && settings.maxRange !== 0 && distance > settings.maxRange) { status = Status.OutOfRange; } const delta = { context: 'vessels.self', updates: [{ source: { label: `plugin.id` }, timestamp: position.timestamp, values: [ { path: `assets.${vessel.assetType}.${vessel.id}.name`, value: vessel.name, }, { path: `assets.${vessel.assetType}.${vessel.id}.position`, value: position.value, }, { path: `assets.${vessel.assetType}.${vessel.id}.speed`, value: speed === undefined ? null : speed.value, }, { path: `assets.${vessel.assetType}.${vessel.id}.heading`, value: heading === undefined ? null : heading.value, }, { path: `assets.${vessel.assetType}.${vessel.id}.distance`, value: Math.round(distance), }, { path: `assets.${vessel.assetType}.${vessel.id}.bearingFromSelf`, value: degreeToRadian(bearingFromSelf), }, { path: `assets.${vessel.assetType}.${vessel.id}.status`, value: status, }, { path: `assets.${vessel.assetType}.${vessel.id}.previousStatus`, value: previousStatus, }, { path: `assets.${vessel.assetType}.${vessel.id}.statusChanged`, value: previousStatus !== status, }, ], }], }; app.handleMessage(plugin.id, delta); vessel.lastRange = Math.round(distance); } else { app.debug(`Skipping outdated vessel '${vessel.name}', age=${ageMinutes.toFixed(1)}minutes, lastRange=${vessel.lastRange}(m), mmsi=${vessel.id}`); // The vessel (tender) is either out of AIS range, or on board and switched off. switch (vessel.assetType) { case AssetType.Fleet: if (vessel.lastRange !== undefined) { status = Status.OutOfRange; } break; default: if (vessel.lastRange !== undefined) { status = vessel.lastRange < ONBOARD_RANGE ? Status.OnBoard : Status.OutOfRange; } break; } const delta = { context: 'vessels.self', updates: [{ source: { label: `plugin.id` }, timestamp: new Date().toISOString(), values: [ { path: `assets.${vessel.assetType}.${vessel.id}.status`, value: status, }, { path: `assets.${vessel.assetType}.${vessel.id}.previousStatus`, value: previousStatus, }, { path: `assets.${vessel.assetType}.${vessel.id}.statusChanged`, value: previousStatus !== status, }, { path: `assets.${vessel.assetType}.${vessel.id}.position`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.speed`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.heading`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.distance`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.bearingFromSelf`, value: null, }, ], }], }; app.handleMessage(plugin.id, delta); } switch (status) { case Status.InRange: inRange.push(vessel.id); break; case Status.OnBoard: onBoard.push(vessel.id); break; case Status.OutOfRange: outOfRange.push(vessel.id); break; default: break; } } else { if (yachtPosition === undefined || !yachtPosition.value) { app.setPluginError(`No position of main yacht available.`); } else { app.debug(`No position data for vessel: ${vessel.name} ${vessel.id}.`); } if (previousStatus !== Status.Unknown) { plugin.clearVessel(vessel, previousStatus); } } }); plugin.outOfRange = outOfRange; plugin.inRange = inRange; plugin.onBoard = onBoard; }, clearVessel: (vessel, prevStatus = Status.Unknown) => { app.debug(`clearVessel(): Clearing data for vessel: ${vessel.name} ${vessel.id}.`); const delta = { context: 'vessels.self', updates: [{ source: { label: `plugin.id` }, timestamp: new Date().toISOString(), values: [ { path: `assets.${vessel.assetType}.${vessel.id}.name`, value: vessel.name, }, { path: `assets.${vessel.assetType}.${vessel.id}.position`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.speed`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.heading`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.distance`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.bearingFromSelf`, value: null, }, { path: `assets.${vessel.assetType}.${vessel.id}.status`, value: Status.Unknown, }, { path: `assets.${vessel.assetType}.${vessel.id}.previousStatus`, value: prevStatus, }, { path: `assets.${vessel.assetType}.${vessel.id}.statusChanged`, value: prevStatus !== Status.Unknown, }, ], }], }; app.handleMessage(plugin.id, delta); }, meta: () => { const metaDelta = { context: "vessels.self", updates: [ { timestamp: new Date().toISOString(), meta: [ { path: 'assets.fleet.*.position', value: { displayName: "Position of the vessel (latitude, longitude).", } }, { path: 'assets.tender.*.position', value: { displayName: "Position of the vessel (latitude, longitude).", } }, { path: 'assets.fleet.*.speed', value: { units: 'm/s', displayName: "Speed of the vessel.", } }, { path: 'assets.tender.*.speed', value: { units: 'm/s', displayName: "Speed of the vessel.", } }, { path: 'assets.fleet.*.heading', value: { units: 'rad', displayName: "Heading of the vessel.", } }, { path: 'assets.tender.*.heading', value: { units: 'rad', displayName: "Heading of the vessel.", } }, { path: 'assets.fleet.*.distance', value: { units: 'm', displayName: "Distance between the vessel and the yacht.", } }, { path: 'assets.tender.*.distance', value: { units: 'm', displayName: "Distance between the vessel and the yacht.", } }, { path: 'assets.*.*.bearingFromSelf', value: { units: 'rad', displayName: "Bearing from the yacht to the vessel.", } }, { path: 'assets.tender.*.status', value: { displayName: "Status of the vessel (unknown, on-board, in-range, out-of-range).", }, }, { path: 'assets.fleet.*.status', value: { displayName: "Status of the vessel (unknown, on-board, in-range, out-of-range).", }, }, { path: 'assets.*.*.previousStatus', value: { displayName: "Previous status of the vessel (unknown, on-board, in-range, out-of-range).", }, }, { path: 'assets.*.*.statusChanged', value: { displayName: "True if the status has changed.", }, }, ] } ] }; return metaDelta; }, schema: () => { return { type: 'object', required: [], properties: { interval: { type: 'number', title: 'Interval to poll for tracked vessels (seconds)', default: 60 }, maxRange: { type: 'number', title: 'Maximum range to the main vessel (meters), use 0 to accept all.', default: 0, }, // minRange: { // type: 'number', // title: 'Minimum range to the main vessel (meters), within this range a tender is assumed to be on-board.', // default: NauticalMileInMeter * 0.1, // ?? // }, maxAge: { type: 'number', title: 'Maximum age of the position data (minutes).', default: 10, }, vesselList: { type: 'array', title: 'List of Vessels (Tenders or Fleet).', default: [], items: { type: 'object', required: ['name', 'id'], properties: { name: { type: 'string', title: 'Name of the tender/vessel', }, id: { type: 'string', title: 'MMSI or other unique ID', }, source: { type: 'string', title: 'Source of the data (AIS or Seetrac (future))', default: 'AIS', enum: ['AIS'], }, assetType: { type: 'string', title: 'Type of asset (Tender, Fleet)', default: 'tender', enum: ['tender', 'fleet'], }, } } } } }; }, }; return plugin; }; // Utilities: const NauticalMileInMeter = 1852.0; const EarthRadiusKM = 6371.01; const EarthRadiusNM = 3441.596; const degreeToRadian = (degree) => { return (degree * Math.PI) / 180.0; }; const radianToDegree = (radian) => { return (radian * 180.0) / Math.PI; }; /** * Calculate the distance from point-1 (from) to point-2 (to). * Using Spherical Law of Cosines. * @param fromLatitude Latitude of point 1 (degrees). * @param fromLongitude Longitude of point 1 (degrees). * @param toLatitude Latitude of point 2 (degrees). * @param toLongitude Longitude of point 2 (degrees). * @returns the distance in Nautical miles. */ const distanceBetweenNauticalMiles = (fromLatitude, fromLongitude, toLatitude, toLongitude) => { let rv; const fromLatitudeRadians = degreeToRadian(fromLatitude); const toLatitudeRadians = degreeToRadian(toLatitude); rv = EarthRadiusNM * Math.acos(Math.sin(fromLatitudeRadians) * Math.sin(toLatitudeRadians) + Math.cos(fromLatitudeRadians) * Math.cos(toLatitudeRadians) * Math.cos(degreeToRadian(toLongitude) - degreeToRadian(fromLongitude))); if (isNaN(rv)) { rv = 0; } return rv; }; /** * Calculate the distance from point-1 (from) to point-2 (to). * Using Spherical Law of Cosines. * @param fromLatitude Latitude of point 1 (degrees). * @param fromLongitude Longitude of point 1 (degrees). * @param toLatitude Latitude of point 2 (degrees). * @param toLongitude Longitude of point 2 (degrees). * @returns the distance in meters. */ const distanceBetweenMeters = (fromLatitude, fromLongitude, toLatitude, toLongitude) => { const miles = distanceBetweenNauticalMiles(fromLatitude, fromLongitude, toLatitude, toLongitude); return miles * NauticalMileInMeter; }; /** * Calculate the bearing from point-1 (from) to point-2 (to). * @param fromLatitude Latitude of point 1 (degrees). * @param fromLongitude Longitude of point 1 (degrees). * @param toLatitude Latitude of point 2 (degrees). * @param toLongitude Longitude of point 2 (degrees). * @returns the bearing in decimal degrees. */ const bearingTo = (fromLatitude, fromLongitude, toLatitude, toLongitude) => { const lat1 = degreeToRadian(fromLatitude); const lat2 = degreeToRadian(toLatitude); const dlon = degreeToRadian(toLongitude - fromLongitude); const y = Math.sin(dlon) * Math.cos(lat2); const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlon); const brng = (radianToDegree(Math.atan2(y, x)) + 360) % 360; return brng; };