@yachteye/signalk-vessel-tracker-plugin
Version:
YachtEye vessel tracker plugin
528 lines (527 loc) • 25.6 kB
JavaScript
;
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;
};