signalk-ais-target-prioritizer
Version:
A SignalK plugin that priorizes AIS targets according to guard and CPA criteria.
480 lines (431 loc) • 18.7 kB
JavaScript
/*
* signalk-ais-target-prioritizer
* Jeremy Waters <jaffadog@gmail.com>"
*/
;
const schema = require('./schema');
const fs = require('fs');
const path = require('path');
const defaultCollisionProfiles = require('./public/assets/js/defaultCollisionProfiles.json');
var aisUtils;
import("./public/assets/js/ais-utils.mjs").then((module) => {
aisUtils = module;
});
var vesper = require("./vesper-xb8000-emulator.js");
const AGE_OUT_OLD_TARGETS = true;
const TARGET_MAX_AGE = 30 * 60; // max age in seconds - 30 minutes
var selfMmsi;
var selfName;
var selfCallsign;
var selfTypeId;
var selfTarget;
var targets = new Map();
var collisionProfiles;
var options;
module.exports = function (app) {
var plugin = {};
var unsubscribes = [];
var refreshDataModelInterval;
plugin.id = "signalk-ais-target-prioritizer";
plugin.name = "SignalK AIS Target Prioritizer";
plugin.description = "A SignalK plugin that priorizes AIS targets according to guard and CPA criteria";
plugin.start = function (_options) {
app.debug('*** Plugin started with options=', _options);
options = _options;
getCollisionProfiles();
if (options.enableDataPublishing || options.enableAlarmPublishing || options.enableEmulator) {
enablePluginCpaCalculations();
} else {
// if plugin was stopped and started again with options set to not perform calculations, then clear out old targets
targets.clear();
}
if (options.enableEmulator) {
//app.debug("collisionProfiles in index.js", collisionProfiles);
//vesper.collisionProfiles = collisionProfiles;
//vesper.setCollisionProfiles(collisionProfiles);
vesper.start(app, collisionProfiles, selfMmsi, selfName, selfCallsign, selfTypeId, targets, saveCollisionProfiles);
}
};
plugin.stop = function () {
app.debug(`Stopping the plugin`);
unsubscribes.forEach(f => f());
unsubscribes = [];
if (refreshDataModelInterval) { clearInterval(refreshDataModelInterval); }
if (options?.enableEmulator) {
vesper.stop();
}
};
plugin.schema = schema;
plugin.registerWithRouter = (router) => {
// GET /plugins/${plugin.id}/getCollisionProfiles
router.get('/getCollisionProfiles', (req, res) => {
app.debug('getCollisionProfiles', collisionProfiles);
res.json(collisionProfiles);
});
// PUT /plugins/${plugin.id}/setCollisionProfiles
router.put('/setCollisionProfiles', (req, res) => {
var newCollisionProfiles = req.body;
app.debug('setCollisionProfiles', newCollisionProfiles);
// do some basic validation to ensure we have some real config data before saving it
if (!newCollisionProfiles
|| !newCollisionProfiles.current
|| !newCollisionProfiles.anchor
|| !newCollisionProfiles.harbor
|| !newCollisionProfiles.coastal
|| !newCollisionProfiles.offshore
) {
app.error('ERROR - not saving invalid new collision profiles', newCollisionProfiles);
res.status(500).end();
return;
}
// must use Object.assign rather than "collisionProfiles = newCollisionProfiles" to prevent breaking the reference we passed into the vesper emulator
Object.assign(collisionProfiles, newCollisionProfiles);
saveCollisionProfiles();
res.json(collisionProfiles);
});
// GET /plugins/${plugin.id}/muteAllAlarms
router.get('/muteAllAlarms', (req, res) => {
app.debug('muteAllAlarms');
targets.forEach((target, mmsi) => {
if (target.alarmState == "danger" && !target.alarmIsMuted) {
app.debug('muting alarm for target', mmsi, target.name, target.alarmType, target.alarmState);
target.alarmIsMuted = true;
}
});
res.json();
});
// GET /plugins/${plugin.id}/setAlarmIsMuted/:mmsi/:alarmIsMuted
router.get('/setAlarmIsMuted/:mmsi/:alarmIsMuted', (req, res) => {
var mmsi = req.params.mmsi;
var alarmIsMuted = (req.params.alarmIsMuted === "true");
app.debug('setting alarmIsMuted', mmsi, alarmIsMuted);
if (targets.has(mmsi)) {
targets.get(mmsi).alarmIsMuted = alarmIsMuted;
res.json();
} else {
res.status(404).end();
}
});
// GET /plugins/${plugin.id}/getTargets
router.get('/getTargets', (req, res) => {
app.debug('getTargets', targets.size);
res.json(Object.fromEntries(targets));
});
// GET /plugins/${plugin.id}/getTarget/:mmsi
router.get('/getTarget/:mmsi', (req, res) => {
var mmsi = req.params.mmsi;
app.debug('getTarget', mmsi);
if (targets.has(mmsi)) {
res.json(targets.get(mmsi));
} else {
res.status(404).end();
}
});
};
function getCollisionProfiles() {
try {
var dataDirPath = app.getDataDirPath();
var collisionProfilesPath = path.join(dataDirPath, "collisionProfiles.json");
if (fs.existsSync(collisionProfilesPath)) {
app.debug('Reading file', collisionProfilesPath);
collisionProfiles = JSON.parse(fs.readFileSync(collisionProfilesPath).toString());
} else {
app.debug('collisionProfiles.json not found, using defaultCollisionProfiles', collisionProfilesPath);
collisionProfiles = defaultCollisionProfiles;
saveCollisionProfiles();
}
} catch (err) {
app.error('Error reading collisionProfiles.json:', err);
throw new Error('Error reading collisionProfiles.json:', err);
}
}
function saveCollisionProfiles() {
app.debug("saving ", collisionProfiles);
var dataDirPath = app.getDataDirPath();
if (!fs.existsSync(dataDirPath)) {
try {
fs.mkdirSync(dataDirPath, { recursive: true });
} catch (err) {
app.error('Error creating dataDirPath:', err);
throw new Error('Error creating dataDirPath:', err);
}
}
var collisionProfilesPath = path.join(dataDirPath, "collisionProfiles.json");
app.debug('Writing file', collisionProfilesPath);
try {
fs.writeFileSync(collisionProfilesPath, JSON.stringify(collisionProfiles, null, 2));
} catch (err) {
app.error('Error writing collisionProfiles.json:', err);
throw new Error('Error writing collisionProfiles.json:', err);
}
}
function enablePluginCpaCalculations() {
selfMmsi = app.getSelfPath('mmsi');
selfName = app.getSelfPath('name');
selfCallsign = app.getSelfPath('communication') ? app.getSelfPath('communication').callsignVhf : '';
selfTypeId = app.getSelfPath('design.aisShipType') ? app.getSelfPath('design.aisShipType').value.id : '';
// *
// atons.*
// vessels.*
// vessels.self
var localSubscription = {
context: '*', // we need both vessels and atons
subscribe: [
{
// "name" is in the root path
// and "communication.callsignVhf"
// and imo
path: '',
period: 1000,
},
{
path: 'navigation.position',
period: 1000,
},
{
path: 'navigation.courseOverGroundTrue',
period: 1000,
},
{
path: 'navigation.speedOverGround',
period: 1000,
},
{
path: 'navigation.magneticVariation',
period: 1000,
},
{
path: 'navigation.headingTrue',
period: 1000,
},
{
path: 'navigation.state',
period: 1000,
},
{
path: 'navigation.destination.commonName',
period: 1000,
},
{
path: 'navigation.rateOfTurn',
period: 1000,
},
{
path: 'design.*',
period: 1000,
},
{
path: 'sensors.ais.class',
period: 1000,
},
{
path: 'atonType',
period: 1000,
},
{
path: 'offPosition',
period: 1000,
},
{
path: 'virtual',
period: 1000,
},
]
};
app.subscriptionmanager.subscribe(
localSubscription,
unsubscribes,
subscriptionError => {
app.error('Error:' + subscriptionError);
},
delta => processDelta(delta)
);
// update data model every 1 second
refreshDataModelInterval = setInterval(refreshDataModel, 1000);
}
function processDelta(delta) {
var updates = delta.updates;
var mmsi = delta.context.slice(-9);
//app.debug('processDelta', mmsi, delta.updates.length, delta.updates[0].values[0]);
if (!mmsi || !/[0-9]{9}/.test(mmsi)) {
app.debug('ERROR: received a delta with an invalid mmsi', JSON.stringify(delta, null, "\t"));
return
}
var target = targets.get(mmsi);
if (!target) {
target = {
// initialize these to zero - because signal k may not set values if the target is stationary
// and we may as well start computing CPAs assuming they're stationary
sog: 0,
cog: 0,
};
target.mmsi = mmsi;
}
target.context = delta.context;
for (let update of updates) {
let values = update.values;
for (let value of values) {
//app.debug('value', value);
switch (value.path) {
case '':
if (value.value.name) {
target.name = value.value.name;
}
else if (value.value.communication && value.value.communication.callsignVhf) {
target.callsign = value.value.communication.callsignVhf;
}
else if (value.value.registrations && value.value.registrations.imo) {
target.imo = value.value.registrations.imo.replace(/imo/i, '');
}
else if (value.value.mmsi) {
// we expected mmsi
}
else {
//app.debug('received unexpected delta on root path', delta.context, value.path, value.value);
}
break;
case 'navigation.position':
target.latitude = value.value.latitude;
target.longitude = value.value.longitude;
target.lastSeenDate = new Date(update.timestamp);
break;
case 'navigation.courseOverGroundTrue':
target.cog = value.value;
break;
case 'navigation.speedOverGround':
target.sog = value.value;
break;
case 'navigation.magneticVariation':
target.magvar = value.value;
break;
case 'navigation.headingTrue':
target.hdg = value.value;
break;
case 'navigation.rateOfTurn':
target.rot = value.value;
break;
case 'design.aisShipType':
target.typeId = value.value.id;
target.type = value.value.name;
break;
case 'navigation.state':
target.status = value.value;
break;
case 'sensors.ais.class':
target.aisClass = value.value;
break;
case 'navigation.destination.commonName':
target.destination = value.value;
break;
case 'design.length':
target.length = value.value.overall;
break;
case 'design.beam':
target.width = value.value;
break;
case 'design.draft':
target.draft = value.value.current;
break;
case 'atonType':
target.typeId = value.value.id;
target.type = value.value.name;
if (target.status == null) {
target.status = 'default'; // 15 = "default"
}
break;
case 'offPosition':
target.isOffPosition = value.value ? 1 : 0;
break;
case 'virtual':
target.isVirtual = value.value ? 1 : 0;
break;
default:
//app.debug('received unexpected delta', delta.context, value.path, value.value);
}
}
}
targets.set(mmsi, target);
}
async function refreshDataModel() {
try {
// collisionProfiles.setFromIndex = Math.floor(new Date().getTime() / 1000);
// app.debug('index.js: setFromIndex,setFromEmulator', collisionProfiles.setFromIndex, collisionProfiles.setFromEmulator, collisionProfiles.anchor.guard.range);
// app.debug("collisionProfiles.anchor.guard.range - index ",collisionProfiles.anchor.guard.range);
if (aisUtils) {
aisUtils.updateDerivedData(targets, selfTarget, collisionProfiles, TARGET_MAX_AGE);
} else {
app.debug("aisUtils not ready...");
return;
}
selfTarget = targets.get(selfMmsi);
targets.forEach((target, mmsi) => {
if (options.enableDataPublishing && mmsi != selfMmsi) {
pushTargetDataToSignalK(target);
}
// publish warning/alarm notifications
// FIXME - should we send 1 notification for all targets? or separate notifications for each target?
if (options.enableAlarmPublishing && target.alarmState && !target.alarmIsMuted) {
var message = (`${target.name || '<' + target.mmsi + '>'} - `
+ `${target.alarmType} `
+ `${(target.alarmState == "danger" ? "alarm" : target.alarmState)}`).toUpperCase();
if (target.alarmState == "warning") {
sendNotification("warn", message);
}
else if (target.alarmState == "danger") {
sendNotification("alarm", message);
}
}
if (AGE_OUT_OLD_TARGETS && target.lastSeen > TARGET_MAX_AGE) {
app.debug('ageing out target', target.mmsi, target.name, target.lastSeen);
targets.delete(target.mmsi);
}
});
}
catch (err) {
app.debug('error in refreshDataModel', err.message, err);
}
}
function pushTargetDataToSignalK(target) {
app.handleMessage(plugin.id, {
"context": target.context,
"updates": [
{
"values": [
{
"path": "navigation.closestApproach",
"value": {
"distance": target.cpa,
"timeTo": target.tcpa,
"range": target.range,
"bearing": target.bearing,
"collisionRiskRating": target.order,
"collisionAlarmType": target.alarmType,
"collisionAlarmState": target.alarmState,
}
}
]
}
]
});
}
function sendNotification(state, message) {
app.debug('sendNotification', state, message);
var delta = {
"updates": [
{
"values": [
{
"path": "notifications.navigation.closestApproach",
"value": {
"state": state,
"method": ["visual", "sound"],
"message": message,
}
}]
}
]
}
app.handleMessage(plugin.id, delta);
}
return plugin;
};