@signalk/freeboard-sk
Version:
Openlayers chart plotter implementation for Signal K
674 lines (673 loc) • 22.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.shutdownAlarms = exports.initAlarms = void 0;
const tslib_1 = require("tslib");
const server_api_1 = require("@signalk/server-api");
const uuid = tslib_1.__importStar(require("uuid"));
const geolib_1 = require("geolib");
const AREA_TRIGGERS = ['entry', 'exit'];
const AREA_GEOMETRIES = ['polygon', 'circle', 'region'];
const STANDARD_ALARMS = [
'mob',
'fire',
'sinking',
'flooding',
'collision',
'grounding',
'listing',
'adrift',
'piracy',
'abandon',
'aground'
];
const ALARM_API_PATH = '/signalk/v2/api/alarms';
class AreaAlarmManager {
alarms;
constructor() {
this.alarms = new Map();
}
/**
* Remove area from alarm manager
* @param id Area identifier
*/
delete(id) {
// clean up notification
this.alarms.delete(id);
emitNotification({
path: `notifications.area.${id}`,
value: null
});
}
/**
* Trigger alarm status update assessment
* @param id Area identifier
* @param condition current condition
* @returns void
*/
update(id, condition) {
if (!alarmAreas.has(id)) {
return;
}
if (!this.alarms.has(id)) {
this.alarms.set(id, {
alarmId: id,
active: false,
lastUpdate: Date.now() - 1000
});
}
this.assessStatus(id, condition);
}
/**
* Silence alarm with the supplied identifier
* @param id Area identifier
*/
silence(id) {
// clean up notification
const n = getSelfPathValue(`notifications.area.${id}`);
if (n?.value && Array.isArray(n.value.method)) {
const m = n.value.method.filter((i) => i !== 'sound');
n.value.method = m;
}
emitNotification({
path: `notifications.area.${id}`,
value: n?.value
});
}
/**
* Assess and emit alarm based on supplied condition
* @param id alarm id
* @param condition current condition
*/
assessStatus(id, condition) {
if (!alarmAreas.has(id)) {
return;
}
const area = alarmAreas.get(id);
const alarm = this.alarms.get(id);
let notify = false;
if (area.trigger === 'entry') {
if (condition === 'inside' && !alarm.active) {
// transition to active
alarm.active = true;
notify = true;
server.debug(`*** inactive -> to active (${id})`);
}
if (condition === 'outside' && alarm.active) {
// transition to inactive
alarm.active = false;
notify = true;
server.debug(`*** active -> to inactive (${id})`);
}
alarm.lastUpdate == Date.now();
}
else {
if (condition === 'outside' && !alarm.active) {
// transition to active
alarm.active = true;
notify = true;
server.debug(`*** inactive -> to active (${id})`);
}
if (condition === 'inside' && alarm.active) {
// transition to inactive
alarm.active = false;
notify = true;
server.debug(`*** active -> to inactive (${id})`);
}
}
if (notify) {
const msg = area.trigger === 'entry'
? alarm.active
? `Monitored area ${area.name ? area.name + ' ' : ''}has been entered.`
: ''
: alarm.active
? `Vessel has left the monitored area ${area.name ?? ''}`
: '';
const state = alarm.active ? server_api_1.ALARM_STATE.alarm : server_api_1.ALARM_STATE.normal;
emitNotification({
path: `notifications.area.${id}`,
value: {
message: msg,
method: [server_api_1.ALARM_METHOD.sound, server_api_1.ALARM_METHOD.visual],
state: state
}
});
}
}
}
// ******************************************************************
let server;
let pluginId;
let unsubscribes = [];
const alarmAreas = new Map();
const alarmManager = new AreaAlarmManager();
const getSelfPathValue = (path) => {
return server.getSelfPath(path);
};
const initAlarms = (app, id) => {
server = app;
pluginId = id;
server.debug(`** initAlarms() **`);
if (server.registerActionHandler) {
server.debug(`** Registering Alarm Action Handler(s) **`);
STANDARD_ALARMS.forEach((i) => {
server.debug(`** Registering ${i} Handler **`);
server.registerPutHandler('vessels.self', `notifications.${i}`, handleV1PutRequest, pluginId);
});
}
initAlarmEndpoints();
setTimeout(() => parseRegionList(), 5000);
// subscribe to deltas
const subCommand = {
context: 'vessels.self',
subscribe: [
{
path: 'resources.*',
policy: 'instant'
},
{
path: 'navigation.position',
policy: 'instant'
}
]
};
server.subscriptionmanager.subscribe(subCommand, unsubscribes, (err) => {
console.log(`error: ${err}`);
}, handleDeltaMessage);
};
exports.initAlarms = initAlarms;
const shutdownAlarms = () => {
unsubscribes.forEach((s) => s());
unsubscribes = [];
};
exports.shutdownAlarms = shutdownAlarms;
const handleDeltaMessage = (delta) => {
if (!delta.updates) {
return;
}
delta.updates.forEach((u) => {
if (!(0, server_api_1.hasValues)(u)) {
return;
}
u.values.forEach((v) => {
const t = v.path.split('.');
if (t[0] === 'resources' && t[1] === 'regions') {
processRegionUpdate(t[2], v.value);
}
if (t[0] === 'navigation' && t[1] === 'position') {
processVesselPositionUpdate(v.value);
}
});
});
};
const initAlarmEndpoints = async () => {
server.debug(`** Registering Alarm Action API endpoint(s) **`);
// list area alarms
server.get(`${ALARM_API_PATH}/area`, async (req, res, next) => {
server.debug(`** ${req.method} ${req.path}`);
const ar = Array.from(alarmAreas);
res.status(200).json(ar);
});
// new area alarm
server.post(`${ALARM_API_PATH}/area`, async (req, res, next) => {
try {
validateAreaBody(req.body);
}
catch (err) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: err.message
});
return;
}
if (req.body.geometry === 'region') {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: `Invalid geometry value 'region'. Use PUT request specifying a region identifier.`
});
return;
}
const id = uuid.v4();
alarmAreas.set(id, req.body);
res.status(200).json({
state: 'COMPLETE',
statusCode: 200,
message: `Alarm Area created: ${id}`
});
});
server.put(`${ALARM_API_PATH}/area/:id`, async (req, res, next) => {
server.debug(`** ${req.method} ${req.path}`);
try {
validateAreaBody(req.body);
}
catch (err) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: err.message
});
return;
}
if (req.body.geometry === 'region') {
// use region resource as alarm area
try {
const reg = await fetchRegion(req.params.id);
const coords = parseRegionCoords(reg);
if (Array.isArray(coords)) {
alarmAreas.set(req.params.id, {
geometry: req.body.geometry,
trigger: req.body.trigger,
coords: coords,
name: reg.name
});
res.status(200).json({
state: 'COMPLETE',
statusCode: 200,
message: `Alarm set for region: ${req.params.id}`
});
}
else {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: `Region not found!`
});
}
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
}
else {
//updateArea(req.params.id)
// use supplied coords as alarm area
const msg = alarmAreas.has(req.params.id)
? `Alarm Area updated: ${req.params.id}`
: `Alarm Area created: ${req.params.id}`;
alarmAreas.set(req.params.id, req.body);
res.status(200).json({
state: 'COMPLETE',
statusCode: 200,
message: msg
});
}
});
server.delete(`${ALARM_API_PATH}/area/:id`, (req, res, next) => {
server.debug(`** ${req.method} ${req.path}`);
try {
if (alarmAreas.has(req.params.id)) {
deleteArea(req.params.id);
res.status(200).json({
state: 'COMPLETE',
statusCode: 200,
message: `Alarm Area Cleared: ${req.params.id}`
});
}
else {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: `Area not found!`
});
}
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
});
server.post(`${ALARM_API_PATH}/area/:id/silence`, (req, res) => {
server.debug(`** ${req.method} ${req.path}`);
try {
if (alarmAreas.has(req.params.id)) {
alarmManager.silence(req.params.id);
res.status(200).json({
state: 'COMPLETE',
statusCode: 200,
message: `Alarm silenced: ${req.params.id}`
});
}
else {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: `Area not found!`
});
}
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
});
const f = await server.getFeatures();
// test for notifications API
if (!f.apis.includes('notifications')) {
// standard alarms
server.post(`${ALARM_API_PATH}/:alarmType`, (req, res, next) => {
server.debug(`** ${req.method} ${ALARM_API_PATH}/${req.params.alarmType}`);
if (!STANDARD_ALARMS.includes(req.params.alarmType)) {
next();
return;
}
try {
const id = uuid.v4();
const msg = req.body.message
? req.body.message
: req.params.alarmType;
const r = handleAlarm('vessels.self', `notifications.${req.params.alarmType}.${id}`, Object.assign({
message: msg,
method: [server_api_1.ALARM_METHOD.sound, server_api_1.ALARM_METHOD.visual],
state: server_api_1.ALARM_STATE.emergency
}, buildAlarmData()));
res.status(r.statusCode).json(Object.assign(r, { id: id }));
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
});
server.post(`${ALARM_API_PATH}/:alarmType/:id/silence`, (req, res) => {
server.debug(`** ${req.method} ${req.path}`);
if (!STANDARD_ALARMS.includes(req.params.alarmType)) {
res.status(200).json({
state: 'COMPLETED',
statusCode: 200,
message: `Unsupported Alarm (${req.params.alarmType}).`
});
return;
}
try {
const al = getSelfPathValue(`notifications.${req.params.alarmType}.${req.params.id}`);
if (al?.value) {
server.debug('Alarm value....');
if (al.value.method &&
al.value.method.includes(server_api_1.ALARM_METHOD.sound)) {
server.debug('Alarm has sound... silence!!!');
al.value.method = al.value.method.filter((i) => i !== server_api_1.ALARM_METHOD.sound);
const r = handleAlarm('vessels.self', `notifications.${req.params.alarmType}.${req.params.id}`, al.value);
res.status(r.statusCode).json(r);
}
else {
server.debug('Alarm has no sound... no action required.');
res.status(200).json({
state: 'COMPLETED',
statusCode: 200,
message: `Alarm (${req.params.alarmType}) is already silent.`
});
}
}
else {
throw new Error(`Alarm (${req.params.alarmType}.${req.params.id}) has no value or was not found!`);
}
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
});
server.delete(`${ALARM_API_PATH}/:alarmType/:id`, (req, res, next) => {
server.debug(`** ${req.method} ${ALARM_API_PATH}/${req.params.alarmType}`);
if (!STANDARD_ALARMS.includes(req.params.alarmType)) {
next();
return;
}
try {
const r = handleAlarm('vessels.self', `notifications.${req.params.alarmType}.${req.params.id}`, {
message: '',
method: [],
state: server_api_1.ALARM_STATE.normal
});
res.status(r.statusCode).json(r);
}
catch (e) {
res.status(400).json({
state: 'FAILED',
statusCode: 400,
message: e.message
});
}
});
}
};
const handleV1PutRequest = (context, path, value, cb) => {
cb(handleAlarm(context, path, value));
};
const buildAlarmData = () => {
const pos = getSelfPathValue('navigation.position');
const r = {
createdAt: new Date().toISOString()
};
if (pos?.value) {
r.position = pos.value;
}
return r;
};
const handleAlarm = (context, path, value) => {
server.debug(`context: ${context}`);
server.debug(`path: ${path}`);
server.debug(`value: ${JSON.stringify(value)}`);
if (!path) {
server.debug('Error: no path provided!');
return {
state: 'COMPLETED',
resultStatus: 400,
statusCode: 400,
message: `Invalid reference!`
};
}
const pa = path.split('.');
const alarmType = pa[1];
server.debug(`alarmType: ${JSON.stringify(alarmType)}`);
if (STANDARD_ALARMS.includes(alarmType)) {
server.debug(`****** Sending Delta (Std Alarm Notification): ******`);
emitNotification({
path: path,
value: value ?? null
});
return { state: 'COMPLETED', resultStatus: 200, statusCode: 200 };
}
else {
return {
state: 'COMPLETED',
resultStatus: 400,
statusCode: 400,
message: `Invalid reference!`
};
}
};
// emit notification delta message **
const emitNotification = (msg) => {
const delta = {
updates: [{ values: [msg] }]
};
server.handleMessage(pluginId, delta, server_api_1.SKVersion.v2);
};
// ********** Area Alarm methods ***************
/**
* Remove Area from management
* @param id Area identifier
*/
const deleteArea = (id) => {
alarmAreas.delete(id);
alarmManager.delete(id);
};
/**
* Validate Area Alarm request parameters
* @param body request body
*/
const validateAreaBody = (body) => {
if (!body.trigger) {
body.trigger = 'entry';
}
else if (!AREA_TRIGGERS.includes(body.trigger)) {
throw new Error(`Area alarm trigger is invalid!`);
}
if (!body.geometry) {
body.geometry = 'polygon';
}
else if (!AREA_GEOMETRIES.includes(body.geometry)) {
throw new Error(`Area alarm geometry is invalid!`);
}
if (body.geometry === 'polygon') {
if (!Array.isArray(body.coords)) {
throw new Error(`Area coordinates not provided or are invalid!`);
}
if (body.coords.length === 0) {
throw new Error(`Area coordinates not provided!`);
}
else if (!isValidPosition(body.coords[0])) {
throw new Error(`Area coordinates are invalid!`);
}
delete body.center;
delete body.radius;
}
if (body.geometry === 'circle') {
if (!body.center || !isValidPosition(body.center)) {
throw new Error(`Center coordinate not provided or is invalid!`);
}
if (typeof body.radius !== 'number') {
throw new Error(`Radius not provided or invalid!`);
}
delete body.coords;
}
if (body.geometry === 'region') {
delete body.coords;
delete body.center;
delete body.radius;
}
};
/**
* Determines if supplied position is valid
* @param position
* @returns true if valid
*/
const isValidPosition = (position) => {
return position &&
'latitude' in position &&
'longitude' in position &&
typeof position.latitude === 'number' &&
position.latitude >= -90 &&
position.latitude <= 90 &&
typeof position.longitude === 'number' &&
position.longitude >= -180 &&
position.longitude <= 180
? true
: false;
};
/**
* Fetch region resource details
* @param id Region identifier
* @returns coordinates array
*/
const fetchRegion = async (id) => {
const reg = await server.resourcesApi.getResource('regions', id);
return reg;
};
/**
* Fetch list of region resources and parse them to assign alarm area
* @returns void
*/
const parseRegionList = async () => {
const regList = await server.resourcesApi.listResources('regions', undefined);
Object.entries(regList).forEach((r) => processRegionUpdate(r[0], r[1]));
};
/**
* Extract and format region coordinates
* @param region Region data
* @returns coordinates array
*/
const parseRegionCoords = (region) => {
let c;
if (region.feature.geometry?.type === 'MultiPolygon') {
c = region.feature.geometry?.coordinates[0][0];
}
else {
c = region.feature.geometry?.coordinates[0];
}
return c.map((i) => {
return { latitude: i[1], longitude: i[0] };
});
};
/**
* CrUD area alarm from Region delta
* @param id Region identifier
* @param region Region data
*/
const processRegionUpdate = (id, region) => {
if (alarmAreas.has(id)) {
if (!region) {
deleteArea(id);
}
else if (region.feature.properties.skIcon !== 'hazard') {
deleteArea(id);
}
else {
const r = alarmAreas.get(id);
r.coords = parseRegionCoords(region);
r.name = region.name;
alarmAreas.set(id, r);
}
}
else {
if (region.feature.properties.skIcon === 'hazard') {
alarmAreas.set(id, {
trigger: 'entry',
geometry: 'region',
coords: parseRegionCoords(region),
name: region.name
});
}
}
};
/**
* Process received vessel.position update delta and
* determine the current each managed area's trigger condition
* @param position Vessel position
*/
const processVesselPositionUpdate = (position) => {
if (!isValidPosition(position)) {
return;
}
alarmAreas.forEach((v, k) => {
let condition;
if (v.geometry === 'circle') {
if ((0, geolib_1.isPointWithinRadius)(position, v.center, v.radius)) {
condition = 'inside';
server.debug(`Vessel inside alarm radius ${k}`);
}
else {
condition = 'outside';
server.debug(`Vessel outside alarm radius ${k}`);
}
}
else {
if ((0, geolib_1.isPointInPolygon)(position, v.coords)) {
condition = 'inside';
server.debug(`Vessel inside alarm area ${k}`);
}
else {
condition = 'outside';
server.debug(`Vessel outside alarm area ${k}`);
}
}
alarmManager.update(k, condition);
});
};