@signalk/freeboard-sk 2.19.0-beta.1 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/freeboard-sk",
3
- "version": "2.19.0-beta.1",
3
+ "version": "2.19.0",
4
4
  "description": "Openlayers chart plotter implementation for Signal K",
5
5
  "keywords": [
6
6
  "signalk-webapp",
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.initAlarms = void 0;
3
+ exports.shutdownAlarms = exports.initAlarms = void 0;
4
4
  const server_api_1 = require("@signalk/server-api");
5
5
  const uuid = require("uuid");
6
+ const geolib_1 = require("geolib");
7
+ const AREA_TRIGGERS = ['entry', 'exit'];
8
+ const AREA_GEOMETRIES = ['polygon', 'circle', 'region'];
6
9
  const STANDARD_ALARMS = [
7
10
  'mob',
8
11
  'fire',
@@ -16,9 +19,126 @@ const STANDARD_ALARMS = [
16
19
  'abandon',
17
20
  'aground'
18
21
  ];
22
+ const ALARM_API_PATH = '/signalk/v2/api/alarms';
23
+ class AreaAlarmManager {
24
+ alarms;
25
+ constructor() {
26
+ this.alarms = new Map();
27
+ }
28
+ /**
29
+ * Remove area from alarm manager
30
+ * @param id Area identifier
31
+ */
32
+ delete(id) {
33
+ // clean up notification
34
+ this.alarms.delete(id);
35
+ emitNotification({
36
+ path: `notifications.area.${id}`,
37
+ value: null
38
+ });
39
+ }
40
+ /**
41
+ * Trigger alarm status update assessment
42
+ * @param id Area identifier
43
+ * @param condition current condition
44
+ * @returns void
45
+ */
46
+ update(id, condition) {
47
+ if (!alarmAreas.has(id)) {
48
+ return;
49
+ }
50
+ if (!this.alarms.has(id)) {
51
+ this.alarms.set(id, {
52
+ alarmId: id,
53
+ active: false,
54
+ lastUpdate: Date.now() - 1000
55
+ });
56
+ }
57
+ this.assessStatus(id, condition);
58
+ }
59
+ /**
60
+ * Silence alarm with the supplied identifier
61
+ * @param id Area identifier
62
+ */
63
+ silence(id) {
64
+ // clean up notification
65
+ const n = server.getSelfPath(`notifications.area.${id}`);
66
+ if (n.value && Array.isArray(n.value.method)) {
67
+ const m = n.value.method.filter((i) => i !== 'sound');
68
+ n.value.method = m;
69
+ }
70
+ emitNotification({
71
+ path: `notifications.area.${id}`,
72
+ value: n.value
73
+ });
74
+ }
75
+ /**
76
+ * Assess and emit alarm based on supplied condition
77
+ * @param id alarm id
78
+ * @param condition current condition
79
+ */
80
+ assessStatus(id, condition) {
81
+ if (!alarmAreas.has(id)) {
82
+ return;
83
+ }
84
+ const area = alarmAreas.get(id);
85
+ const alarm = this.alarms.get(id);
86
+ let notify = false;
87
+ if (area.trigger === 'entry') {
88
+ if (condition === 'inside' && !alarm.active) {
89
+ // transition to active
90
+ alarm.active = true;
91
+ notify = true;
92
+ server.debug(`*** inactive -> to active (${id})`);
93
+ }
94
+ if (condition === 'outside' && alarm.active) {
95
+ // transition to inactive
96
+ alarm.active = false;
97
+ notify = true;
98
+ server.debug(`*** active -> to inactive (${id})`);
99
+ }
100
+ alarm.lastUpdate == Date.now();
101
+ }
102
+ else {
103
+ if (condition === 'outside' && !alarm.active) {
104
+ // transition to active
105
+ alarm.active = true;
106
+ notify = true;
107
+ server.debug(`*** inactive -> to active (${id})`);
108
+ }
109
+ if (condition === 'inside' && alarm.active) {
110
+ // transition to inactive
111
+ alarm.active = false;
112
+ notify = true;
113
+ server.debug(`*** active -> to inactive (${id})`);
114
+ }
115
+ }
116
+ if (notify) {
117
+ const msg = area.trigger === 'entry'
118
+ ? alarm.active
119
+ ? `Monitored area ${area.name ? area.name + ' ' : ''}has been entered.`
120
+ : ''
121
+ : alarm.active
122
+ ? `Vessel has left the monitored area ${area.name ?? ''}`
123
+ : '';
124
+ const state = alarm.active ? server_api_1.ALARM_STATE.alarm : server_api_1.ALARM_STATE.normal;
125
+ emitNotification({
126
+ path: `notifications.area.${id}`,
127
+ value: {
128
+ message: msg,
129
+ method: [server_api_1.ALARM_METHOD.sound, server_api_1.ALARM_METHOD.visual],
130
+ state: state
131
+ }
132
+ });
133
+ }
134
+ }
135
+ }
136
+ // ******************************************************************
19
137
  let server;
20
138
  let pluginId;
21
- const ALARM_API_PATH = '/signalk/v2/api/alarms';
139
+ let unsubscribes = [];
140
+ const alarmAreas = new Map();
141
+ const alarmManager = new AreaAlarmManager();
22
142
  const initAlarms = (app, id) => {
23
143
  server = app;
24
144
  pluginId = id;
@@ -31,10 +151,203 @@ const initAlarms = (app, id) => {
31
151
  });
32
152
  }
33
153
  initAlarmEndpoints();
154
+ setTimeout(() => parseRegionList(), 5000);
155
+ // subscribe to deltas
156
+ const subCommand = {
157
+ context: 'vessels.self',
158
+ subscribe: [
159
+ {
160
+ path: 'resources.*',
161
+ policy: 'instant'
162
+ },
163
+ {
164
+ path: 'navigation.position',
165
+ policy: 'instant'
166
+ }
167
+ ]
168
+ };
169
+ server.subscriptionmanager.subscribe(subCommand, unsubscribes, (err) => {
170
+ console.log(`error: ${err}`);
171
+ }, handleDeltaMessage);
34
172
  };
35
173
  exports.initAlarms = initAlarms;
174
+ const shutdownAlarms = () => {
175
+ unsubscribes.forEach((s) => s());
176
+ unsubscribes = [];
177
+ };
178
+ exports.shutdownAlarms = shutdownAlarms;
179
+ const handleDeltaMessage = (delta) => {
180
+ if (!delta.updates) {
181
+ return;
182
+ }
183
+ delta.updates.forEach((u) => {
184
+ if (!(0, server_api_1.hasValues)(u)) {
185
+ return;
186
+ }
187
+ u.values.forEach((v) => {
188
+ const t = v.path.split('.');
189
+ if (t[0] === 'resources' && t[1] === 'regions') {
190
+ processRegionUpdate(t[2], v.value);
191
+ }
192
+ if (t[0] === 'navigation' && t[1] === 'position') {
193
+ processVesselPositionUpdate(v.value);
194
+ }
195
+ });
196
+ });
197
+ };
36
198
  const initAlarmEndpoints = () => {
37
199
  server.debug(`** Registering Alarm Action API endpoint(s) **`);
200
+ // list area alarms
201
+ server.get(`${ALARM_API_PATH}/area`, async (req, res, next) => {
202
+ server.debug(`** ${req.method} ${req.path}`);
203
+ const ar = Array.from(alarmAreas);
204
+ res.status(200).json(ar);
205
+ });
206
+ // new area alarm
207
+ server.post(`${ALARM_API_PATH}/area`, async (req, res, next) => {
208
+ try {
209
+ validateAreaBody(req.body);
210
+ }
211
+ catch (err) {
212
+ res.status(400).json({
213
+ state: 'FAILED',
214
+ statusCode: 400,
215
+ message: err.message
216
+ });
217
+ return;
218
+ }
219
+ if (req.body.geometry === 'region') {
220
+ res.status(400).json({
221
+ state: 'FAILED',
222
+ statusCode: 400,
223
+ message: `Invalid geometry value 'region'. Use PUT request specifying a region identifier.`
224
+ });
225
+ return;
226
+ }
227
+ const id = uuid.v4();
228
+ alarmAreas.set(id, req.body);
229
+ res.status(200).json({
230
+ state: 'COMPLETE',
231
+ statusCode: 200,
232
+ message: `Alarm Area created: ${id}`
233
+ });
234
+ });
235
+ server.put(`${ALARM_API_PATH}/area/:id`, async (req, res, next) => {
236
+ server.debug(`** ${req.method} ${req.path}`);
237
+ try {
238
+ validateAreaBody(req.body);
239
+ }
240
+ catch (err) {
241
+ res.status(400).json({
242
+ state: 'FAILED',
243
+ statusCode: 400,
244
+ message: err.message
245
+ });
246
+ return;
247
+ }
248
+ if (req.body.geometry === 'region') {
249
+ // use region resource as alarm area
250
+ try {
251
+ const reg = await fetchRegion(req.params.id);
252
+ const coords = parseRegionCoords(reg);
253
+ if (Array.isArray(coords)) {
254
+ alarmAreas.set(req.params.id, {
255
+ geometry: req.body.geometry,
256
+ trigger: req.body.trigger,
257
+ coords: coords,
258
+ name: reg.name
259
+ });
260
+ res.status(200).json({
261
+ state: 'COMPLETE',
262
+ statusCode: 200,
263
+ message: `Alarm set for region: ${req.params.id}`
264
+ });
265
+ }
266
+ else {
267
+ res.status(400).json({
268
+ state: 'FAILED',
269
+ statusCode: 400,
270
+ message: `Region not found!`
271
+ });
272
+ }
273
+ }
274
+ catch (e) {
275
+ res.status(400).json({
276
+ state: 'FAILED',
277
+ statusCode: 400,
278
+ message: e.message
279
+ });
280
+ }
281
+ }
282
+ else {
283
+ //updateArea(req.params.id)
284
+ // use supplied coords as alarm area
285
+ const msg = alarmAreas.has(req.params.id)
286
+ ? `Alarm Area updated: ${req.params.id}`
287
+ : `Alarm Area created: ${req.params.id}`;
288
+ alarmAreas.set(req.params.id, req.body);
289
+ res.status(200).json({
290
+ state: 'COMPLETE',
291
+ statusCode: 200,
292
+ message: msg
293
+ });
294
+ }
295
+ });
296
+ server.delete(`${ALARM_API_PATH}/area/:id`, (req, res, next) => {
297
+ server.debug(`** ${req.method} ${req.path}`);
298
+ try {
299
+ if (alarmAreas.has(req.params.id)) {
300
+ deleteArea(req.params.id);
301
+ res.status(200).json({
302
+ state: 'COMPLETE',
303
+ statusCode: 200,
304
+ message: `Alarm Area Cleared: ${req.params.id}`
305
+ });
306
+ }
307
+ else {
308
+ res.status(400).json({
309
+ state: 'FAILED',
310
+ statusCode: 400,
311
+ message: `Area not found!`
312
+ });
313
+ }
314
+ }
315
+ catch (e) {
316
+ res.status(400).json({
317
+ state: 'FAILED',
318
+ statusCode: 400,
319
+ message: e.message
320
+ });
321
+ }
322
+ });
323
+ server.post(`${ALARM_API_PATH}/area/:id/silence`, (req, res) => {
324
+ server.debug(`** ${req.method} ${req.path}`);
325
+ try {
326
+ if (alarmAreas.has(req.params.id)) {
327
+ alarmManager.silence(req.params.id);
328
+ res.status(200).json({
329
+ state: 'COMPLETE',
330
+ statusCode: 200,
331
+ message: `Alarm silenced: ${req.params.id}`
332
+ });
333
+ }
334
+ else {
335
+ res.status(400).json({
336
+ state: 'FAILED',
337
+ statusCode: 400,
338
+ message: `Area not found!`
339
+ });
340
+ }
341
+ }
342
+ catch (e) {
343
+ res.status(400).json({
344
+ state: 'FAILED',
345
+ statusCode: 400,
346
+ message: e.message
347
+ });
348
+ }
349
+ });
350
+ // standard alarms
38
351
  server.post(`${ALARM_API_PATH}/:alarmType`, (req, res, next) => {
39
352
  server.debug(`** ${req.method} ${ALARM_API_PATH}/${req.params.alarmType}`);
40
353
  if (!STANDARD_ALARMS.includes(req.params.alarmType)) {
@@ -171,10 +484,180 @@ const handleAlarm = (context, path, value) => {
171
484
  };
172
485
  }
173
486
  };
174
- // ** send notification delta message **
487
+ // emit notification delta message **
175
488
  const emitNotification = (msg) => {
176
489
  const delta = {
177
490
  updates: [{ values: [msg] }]
178
491
  };
179
492
  server.handleMessage(pluginId, delta, server_api_1.SKVersion.v2);
180
493
  };
494
+ // ********** Area Alarm methods ***************
495
+ /**
496
+ * Remove Area from management
497
+ * @param id Area identifier
498
+ */
499
+ const deleteArea = (id) => {
500
+ alarmAreas.delete(id);
501
+ alarmManager.delete(id);
502
+ };
503
+ /**
504
+ * Validate Area Alarm request parameters
505
+ * @param body request body
506
+ */
507
+ const validateAreaBody = (body) => {
508
+ if (!body.trigger) {
509
+ body.trigger = 'entry';
510
+ }
511
+ else if (!AREA_TRIGGERS.includes(body.trigger)) {
512
+ throw new Error(`Area alarm trigger is invalid!`);
513
+ }
514
+ if (!body.geometry) {
515
+ body.geometry = 'polygon';
516
+ }
517
+ else if (!AREA_GEOMETRIES.includes(body.geometry)) {
518
+ throw new Error(`Area alarm geometry is invalid!`);
519
+ }
520
+ if (body.geometry === 'polygon') {
521
+ if (!Array.isArray(body.coords)) {
522
+ throw new Error(`Area coordinates not provided or are invalid!`);
523
+ }
524
+ if (body.coords.length === 0) {
525
+ throw new Error(`Area coordinates not provided!`);
526
+ }
527
+ else if (!isValidPosition(body.coords[0])) {
528
+ throw new Error(`Area coordinates are invalid!`);
529
+ }
530
+ delete body.center;
531
+ delete body.radius;
532
+ }
533
+ if (body.geometry === 'circle') {
534
+ if (!body.center || !isValidPosition(body.center)) {
535
+ throw new Error(`Center coordinate not provided or is invalid!`);
536
+ }
537
+ if (typeof body.radius !== 'number') {
538
+ throw new Error(`Radius not provided or invalid!`);
539
+ }
540
+ delete body.coords;
541
+ }
542
+ if (body.geometry === 'region') {
543
+ delete body.coords;
544
+ delete body.center;
545
+ delete body.radius;
546
+ }
547
+ };
548
+ /**
549
+ * Determines if supplied position is valid
550
+ * @param position
551
+ * @returns true if valid
552
+ */
553
+ const isValidPosition = (position) => {
554
+ return 'latitude' in position &&
555
+ 'longitude' in position &&
556
+ typeof position.latitude === 'number' &&
557
+ position.latitude >= -90 &&
558
+ position.latitude <= 90 &&
559
+ typeof position.longitude === 'number' &&
560
+ position.longitude >= -180 &&
561
+ position.longitude <= 180
562
+ ? true
563
+ : false;
564
+ };
565
+ /**
566
+ * Fetch region resource details
567
+ * @param id Region identifier
568
+ * @returns coordinates array
569
+ */
570
+ const fetchRegion = async (id) => {
571
+ const reg = await server.resourcesApi.getResource('regions', id);
572
+ return reg;
573
+ };
574
+ /**
575
+ * Fetch list of region resources and parse them to assign alarm area
576
+ * @returns void
577
+ */
578
+ const parseRegionList = async () => {
579
+ const regList = await server.resourcesApi.listResources('regions', undefined);
580
+ Object.entries(regList).forEach((r) => processRegionUpdate(r[0], r[1]));
581
+ };
582
+ /**
583
+ * Extract and format region coordinates
584
+ * @param region Region data
585
+ * @returns coordinates array
586
+ */
587
+ const parseRegionCoords = (region) => {
588
+ let c;
589
+ if (region.feature.geometry?.type === 'MultiPolygon') {
590
+ c = region.feature.geometry?.coordinates[0][0];
591
+ }
592
+ else {
593
+ c = region.feature.geometry?.coordinates[0];
594
+ }
595
+ return c.map((i) => {
596
+ return { latitude: i[1], longitude: i[0] };
597
+ });
598
+ };
599
+ /**
600
+ * CrUD area alarm from Region delta
601
+ * @param id Region identifier
602
+ * @param region Region data
603
+ */
604
+ const processRegionUpdate = (id, region) => {
605
+ if (alarmAreas.has(id)) {
606
+ if (!region) {
607
+ deleteArea(id);
608
+ }
609
+ else if (region.feature.properties.skIcon !== 'hazard') {
610
+ deleteArea(id);
611
+ }
612
+ else {
613
+ const r = alarmAreas.get(id);
614
+ r.coords = parseRegionCoords(region);
615
+ r.name = region.name;
616
+ alarmAreas.set(id, r);
617
+ }
618
+ }
619
+ else {
620
+ if (region.feature.properties.skIcon === 'hazard') {
621
+ alarmAreas.set(id, {
622
+ trigger: 'entry',
623
+ geometry: 'region',
624
+ coords: parseRegionCoords(region),
625
+ name: region.name
626
+ });
627
+ }
628
+ }
629
+ };
630
+ /**
631
+ * Process received vessel.position update delta and
632
+ * determine the current each managed area's trigger condition
633
+ * @param position Vessel position
634
+ */
635
+ const processVesselPositionUpdate = (position) => {
636
+ if (!isValidPosition(position)) {
637
+ return;
638
+ }
639
+ alarmAreas.forEach((v, k) => {
640
+ let condition;
641
+ if (v.geometry === 'circle') {
642
+ if ((0, geolib_1.isPointWithinRadius)(position, v.center, v.radius)) {
643
+ condition = 'inside';
644
+ server.debug(`Vessel inside alarm radius ${k}`);
645
+ }
646
+ else {
647
+ condition = 'outside';
648
+ server.debug(`Vessel outside alarm radius ${k}`);
649
+ }
650
+ }
651
+ else {
652
+ if ((0, geolib_1.isPointInPolygon)(position, v.coords)) {
653
+ condition = 'inside';
654
+ server.debug(`Vessel inside alarm area ${k}`);
655
+ }
656
+ else {
657
+ condition = 'outside';
658
+ server.debug(`Vessel outside alarm area ${k}`);
659
+ }
660
+ }
661
+ alarmManager.update(k, condition);
662
+ });
663
+ };
package/plugin/index.js CHANGED
@@ -67,6 +67,7 @@ module.exports = (server) => {
67
67
  const doShutdown = () => {
68
68
  server.debug('** shutting down **');
69
69
  server.debug('** Un-subscribing from events **');
70
+ (0, alarms_1.shutdownAlarms)();
70
71
  const msg = 'Stopped';
71
72
  server.setPluginStatus(msg);
72
73
  };
@@ -185,6 +186,13 @@ module.exports = (server) => {
185
186
  units: 'ratio'
186
187
  }
187
188
  });
189
+ metas.push({
190
+ path: `${pathRoot}.outside.precipitationVolume`,
191
+ value: {
192
+ description: 'Precipitation Volume.',
193
+ units: 'm'
194
+ }
195
+ });
188
196
  metas.push({
189
197
  path: `${pathRoot}.wind.averageSpeed`,
190
198
  value: {
@@ -241,6 +249,13 @@ module.exports = (server) => {
241
249
  units: 'K'
242
250
  }
243
251
  });
252
+ metas.push({
253
+ path: `${pathRoot}.water.salinity`,
254
+ value: {
255
+ description: 'Water salinity.',
256
+ units: 'ratio'
257
+ }
258
+ });
244
259
  metas.push({
245
260
  path: `${pathRoot}.water.levelTendency`,
246
261
  value: {