@signalk/course-provider
Version:
Course data provider plugin for SignalK Server.
462 lines (461 loc) • 18.6 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const alarms_1 = require("./lib/alarms");
const types_1 = require("./types");
const path_1 = __importDefault(require("path"));
const worker_threads_1 = require("worker_threads");
const CONFIG_SCHEMA = {
properties: {
notifications: {
type: 'object',
title: 'Notifications',
description: 'Configure the options for generated notifications.',
properties: {
sound: {
type: 'boolean',
title: 'Enable sound'
}
}
},
calculations: {
type: 'object',
title: 'Calculations',
description: 'Configure course calculations options.',
properties: {
method: {
type: 'string',
default: 'GreatCircle',
enum: ['GreatCircle', 'Rhumbline']
}
}
}
}
};
const CONFIG_UISCHEMA = {
notifications: {
sound: {
'ui:widget': 'checkbox',
'ui:title': ' ',
'ui:help': ''
}
},
calculations: {
method: {
'ui:widget': 'radio',
'ui:title': 'Course calculation method',
'ui:help': ' '
}
}
};
const SRC_PATHS = [
'navigation.position',
'navigation.magneticVariation',
'navigation.headingTrue',
'navigation.speedOverGround',
'navigation.datetime',
'navigation.course.arrivalCircle',
'navigation.course.startTime',
'navigation.course.targetArrivalTime',
'navigation.course.nextPoint',
'navigation.course.previousPoint'
];
module.exports = (server) => {
const watchArrival = new alarms_1.Watcher(); // watch distance from arrivalCircle
const watchPassedDest = new alarms_1.Watcher(); // watch passedPerpendicular
watchPassedDest.rangeMin = 1;
watchPassedDest.rangeMax = 2;
let unsubscribes = []; // delta stream subscriptions
let obs = []; // Observables subscription
let worker;
const SIGNALK_API_PATH = `/signalk/v2/api`;
const COURSE_CALCS_PATH = `${SIGNALK_API_PATH}/vessels/self/navigation/course/calcValues`;
const srcPaths = {};
let courseCalcs;
let metaSent = false;
// ******** REQUIRED PLUGIN DEFINITION *******
const plugin = {
id: 'course-provider',
name: 'Course Data provider',
schema: () => CONFIG_SCHEMA,
uiSchema: () => CONFIG_UISCHEMA,
start: (options) => {
doStartup(options);
},
stop: () => {
doShutdown();
}
};
// ************************************
let config = {
notifications: {
sound: false
},
calculations: {
method: 'GreatCircle'
}
};
const doStartup = (options) => {
var _a, _b;
try {
server.debug(`${plugin.name} starting.......`);
if (typeof ((_a = options.notifications) === null || _a === void 0 ? void 0 : _a.sound) !== 'undefined' &&
typeof ((_b = options.calculations) === null || _b === void 0 ? void 0 : _b.method) !== 'undefined') {
config = options;
}
server.debug(`Applied config: ${JSON.stringify(config)}`);
// setup subscriptions
initSubscriptions(SRC_PATHS);
// setup worker(s)
initWorkers();
// setup routes
initEndpoints();
const msg = 'Started';
server.setPluginStatus(msg);
}
catch (error) {
const msg = 'Started with errors!';
server.setPluginError(msg);
server.error('** EXCEPTION: **');
server.error(error.stack);
return error;
}
};
const doShutdown = () => {
server.debug('** shutting down **');
server.debug('** Un-subscribing from events **');
unsubscribes.forEach((s) => s());
unsubscribes = [];
obs.forEach((o) => o.unsubscribe());
obs = [];
if (worker) {
server.debug('** Stopping Worker(s) **');
worker.unref();
}
const msg = 'Stopped';
server.setPluginStatus(msg);
};
// *****************************************
// register DELTA stream message handler
const initSubscriptions = (skPaths) => {
getPaths(skPaths);
const subscription = {
context: 'vessels.self',
subscribe: skPaths.map((p) => ({
path: p,
period: 500
}))
};
server.subscriptionmanager.subscribe(subscription, unsubscribes, (error) => {
server.error(`${plugin.id} Error: ${error}`);
}, (delta) => {
if (!delta.updates) {
return;
}
delta.updates.forEach((u) => {
if (!u.values) {
return;
}
u.values.forEach((v) => {
srcPaths[v.path] = v.value;
if (v.path === 'navigation.position') {
server.debug(`navigation.position ${JSON.stringify(v.value)} => calc()`);
calc();
}
});
});
});
obs.push(watchArrival.change$.subscribe((event) => {
onArrivalCircleEvent(event);
}));
obs.push(watchPassedDest.change$.subscribe((event) => {
onPassedDestEvent(event);
}));
};
// initialise calculation worker(s)
const initWorkers = () => {
worker = new worker_threads_1.Worker(path_1.default.resolve(__dirname, './worker/course.js'));
worker.on('message', (msg) => {
calcResult(msg);
});
worker.on('error', (error) => console.error('** worker.error:', error));
worker.on('exit', (code) => {
if (code !== 0) {
console.error('** worker.exit:', `Stopped with exit code ${code}`);
}
});
};
// initialise api endpoints
const initEndpoints = () => {
server.get(`${COURSE_CALCS_PATH}`, (req, res) => __awaiter(void 0, void 0, void 0, function* () {
server.debug(`** GET ${COURSE_CALCS_PATH}`);
const calcs = config.calculations.method === 'Rhumbline'
? courseCalcs === null || courseCalcs === void 0 ? void 0 : courseCalcs.rl
: courseCalcs === null || courseCalcs === void 0 ? void 0 : courseCalcs.gc;
if (!calcs) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: `No active destination!`
});
return;
}
return res.status(200).json(calcs);
}));
};
// ********* Course Calculations *******************
// retrieve initial values of target paths
const getPaths = (paths) => {
paths.forEach((path) => {
var _a;
const v = server.getSelfPath(path);
srcPaths[path] = (_a = v === null || v === void 0 ? void 0 : v.value) !== null && _a !== void 0 ? _a : null;
});
server.debug(`[srcPaths]: ${JSON.stringify(srcPaths)}`);
};
// trigger course calculations
const calc = () => {
if (srcPaths['navigation.position']) {
worker === null || worker === void 0 ? void 0 : worker.postMessage(srcPaths);
}
};
// send calculation results delta
const calcResult = (result) => __awaiter(void 0, void 0, void 0, function* () {
var _a, _b, _c;
server.debug(`*** calculation result ***`);
watchArrival.rangeMax = (_a = srcPaths['navigation.course.arrivalCircle']) !== null && _a !== void 0 ? _a : -1;
watchArrival.value = (_c = (_b = result.gc) === null || _b === void 0 ? void 0 : _b.distance) !== null && _c !== void 0 ? _c : -1;
watchPassedDest.value = result.passedPerpendicular ? 1 : 0;
courseCalcs = result;
server.handleMessage(plugin.id, buildDeltaMsg(courseCalcs), 'v2');
server.debug(`*** course data delta sent***`);
if (!metaSent) {
server.handleMessage(plugin.id, buildMetaDeltaMsg(), 'v2');
server.debug(`*** meta delta sent***`);
metaSent = true;
}
});
const buildDeltaMsg = (course) => {
var _a, _b;
const values = [];
const calcPath = 'navigation.course.calcValues';
const source = config.calculations.method === 'Rhumbline' ? course.rl : course.gc;
server.debug(`*** building course data delta ***`);
values.push({
path: `${calcPath}.calcMethod`,
value: config.calculations.method
});
values.push({
path: `${calcPath}.bearingTrackTrue`,
value: typeof source.bearingTrackTrue === 'undefined'
? null
: source.bearingTrackTrue
});
values.push({
path: `${calcPath}.bearingTrackMagnetic`,
value: typeof source.bearingTrackMagnetic === 'undefined'
? null
: source.bearingTrackMagnetic
});
values.push({
path: `${calcPath}.crossTrackError`,
value: typeof source.crossTrackError === 'undefined'
? null
: source.crossTrackError
});
values.push({
path: `${calcPath}.previousPoint.distance`,
value: typeof ((_a = source.previousPoint) === null || _a === void 0 ? void 0 : _a.distance) === 'undefined'
? null
: (_b = source.previousPoint) === null || _b === void 0 ? void 0 : _b.distance
});
values.push({
path: `${calcPath}.distance`,
value: typeof (source === null || source === void 0 ? void 0 : source.distance) === 'undefined' ? null : source === null || source === void 0 ? void 0 : source.distance
});
values.push({
path: `${calcPath}.bearingTrue`,
value: typeof (source === null || source === void 0 ? void 0 : source.bearingTrue) === 'undefined' ? null : source === null || source === void 0 ? void 0 : source.bearingTrue
});
values.push({
path: `${calcPath}.bearingMagnetic`,
value: typeof (source === null || source === void 0 ? void 0 : source.bearingMagnetic) === 'undefined'
? null
: source === null || source === void 0 ? void 0 : source.bearingMagnetic
});
values.push({
path: `${calcPath}.velocityMadeGood`,
value: typeof (source === null || source === void 0 ? void 0 : source.velocityMadeGood) === 'undefined'
? null
: source === null || source === void 0 ? void 0 : source.velocityMadeGood
});
values.push({
path: `performance.velocityMadeGoodToWaypoint`,
value: typeof (source === null || source === void 0 ? void 0 : source.velocityMadeGood) === 'undefined'
? null
: source === null || source === void 0 ? void 0 : source.velocityMadeGood
});
values.push({
path: `${calcPath}.timeToGo`,
value: typeof (source === null || source === void 0 ? void 0 : source.timeToGo) === 'undefined' ? null : source === null || source === void 0 ? void 0 : source.timeToGo
});
values.push({
path: `${calcPath}.estimatedTimeOfArrival`,
value: typeof (source === null || source === void 0 ? void 0 : source.estimatedTimeOfArrival) === 'undefined'
? null
: source === null || source === void 0 ? void 0 : source.estimatedTimeOfArrival
});
values.push({
path: `${calcPath}.targetSpeed`,
value: typeof (source === null || source === void 0 ? void 0 : source.targetSpeed) === 'undefined' ? null : source === null || source === void 0 ? void 0 : source.targetSpeed
});
return {
updates: [
{
values: values
}
]
};
};
const buildMetaDeltaMsg = () => {
const metas = [];
const calcPath = 'navigation.course.calcValues';
server.debug(`*** building meta delta ***`);
metas.push({
path: `${calcPath}.calcMethod`,
value: {
description: 'Calculation type used (GreatCircle or Rhumbline).'
}
});
metas.push({
path: `${calcPath}.bearingTrackTrue`,
value: {
description: 'The bearing of a line between previousPoint and nextPoint, relative to true north.',
units: 'rad'
}
});
metas.push({
path: `${calcPath}.bearingTrackMagnetic`,
value: {
description: 'The bearing of a line between previousPoint and nextPoint, relative to magnetic north.',
units: 'rad'
}
});
metas.push({
path: `${calcPath}.crossTrackError`,
value: {
description: "The distance from the vessel's present position to the closest point on a line (track) between previousPoint and nextPoint. A negative number indicates that the vessel is currently to the left of this line (and thus must steer right to compensate), a positive number means the vessel is to the right of the line (steer left to compensate).",
units: 'm'
}
});
metas.push({
path: `${calcPath}.previousPoint.distance`,
value: {
description: "The distance in meters between the vessel's present position and the previousPoint.",
units: 'm'
}
});
metas.push({
path: `${calcPath}.distance`,
value: {
description: "The distance in meters between the vessel's present position and the nextPoint.",
units: 'm'
}
});
metas.push({
path: `${calcPath}.bearingTrue`,
value: {
description: "The bearing of a line between the vessel's current position and nextPoint, relative to true north.",
units: 'rad'
}
});
metas.push({
path: `${calcPath}.bearingMagnetic`,
value: {
description: "The bearing of a line between the vessel's current position and nextPoint, relative to magnetic north.",
units: 'rad'
}
});
metas.push({
path: `${calcPath}.velocityMadeGood`,
value: {
description: 'The velocity component of the vessel towards the nextPoint.',
units: 'm/s'
}
});
metas.push({
path: `${calcPath}.timeToGo`,
value: {
description: "Time in seconds to reach nextPoint's perpendicular) with current speed & direction.",
units: 's'
}
});
metas.push({
path: `${calcPath}.estimatedTimeOfArrival`,
value: {
description: 'The estimated time of arrival at nextPoint position.',
units: 's'
}
});
metas.push({
path: `${calcPath}.targetSpeed`,
value: {
description: 'The average speed required to arrive at the destination at the targetArrivalTime.',
units: 'm/s'
}
});
return {
updates: [
{
meta: metas
}
]
};
};
// ********* Arrival circle events *****************
const onArrivalCircleEvent = (event) => {
server.debug(JSON.stringify(event));
const alarmMethod = config.notifications.sound
? [types_1.ALARM_METHOD.sound, types_1.ALARM_METHOD.visual]
: [types_1.ALARM_METHOD.visual];
if (event.type === 'enter') {
if (srcPaths['navigation.position']) {
emitNotification(new alarms_1.Notification('navigation.course.arrivalCircleEntered', `Entered arrival zone: ${event.value.toFixed(0)}m < ${watchArrival.rangeMax.toFixed(0)}`, types_1.ALARM_STATE.alert, alarmMethod));
}
}
if (event.type === 'exit') {
emitNotification(new alarms_1.Notification('navigation.course.arrivalCircleEntered', null));
}
};
// ********* Passed Destination events *****************
const onPassedDestEvent = (event) => {
server.debug(JSON.stringify(event));
if (event.type === 'enter') {
if (srcPaths['navigation.position']) {
emitNotification(new alarms_1.Notification('navigation.course.perpendicularPassed', watchPassedDest.value.toString(), types_1.ALARM_STATE.alert, []));
}
}
if (event.type === 'exit') {
emitNotification(new alarms_1.Notification('navigation.course.perpendicularPassed', null));
}
};
// send notification delta message
const emitNotification = (notification) => {
server.debug(JSON.stringify(notification === null || notification === void 0 ? void 0 : notification.message));
server.handleMessage(plugin.id, {
updates: [{ values: [notification.message] }]
});
};
return plugin;
};