sk-ais-status-plugin 1.0.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/.prettierrc.json +5 -0
- package/README.md +164 -0
- package/package.json +31 -0
- package/plugin/index.d.ts +1 -0
- package/plugin/index.js +309 -0
- package/plugin/openApi.json +175 -0
package/.prettierrc.json
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Signal K AIS Status Plugin
|
|
2
|
+
This plugin evaluates AIS target reporting continuity and maintains a per-target tracking state using class-specific timing thresholds. It publishes `sensors.ais.status` deltas to Signal K, enabling consuming applications to assess target validity, reliability, and reporting continuity.
|
|
3
|
+
|
|
4
|
+
## About
|
|
5
|
+
AIS reception is often intermittent (collisions, range, antenna shadowing), so a single position report does not necessarily mean a target is reliably tracked, and an old position may be unsafe to treat as current. The published state (`unconfirmed`, `confirmed`, `lost`, `remove`) lets clients display and filter targets appropriately and avoid “ghost” or stale tracks.
|
|
6
|
+
|
|
7
|
+
### A Note About Client State Interpretation
|
|
8
|
+
Client applications are free to interpret the state, but the basic intended UX is:
|
|
9
|
+
|
|
10
|
+
- `unconfirmed`: show the target, but de-emphasize it (e.g. faded / dotted / “suspect”). Avoid using it for alarms until it becomes confirmed.
|
|
11
|
+
- `confirmed`: show normally and treat as an active track.
|
|
12
|
+
- `lost`: keep showing the last known position briefly, but clearly indicate staleness (e.g. greyed out with “age since last update”). Typically suppress CPA/guard-zone style alarms for lost targets.
|
|
13
|
+
- `remove`: remove the target from display and lists.
|
|
14
|
+
|
|
15
|
+
Keeping `unconfirmed` and `lost` targets visible (but styled differently) helps avoid clutter from one-off decodes while still preserving situational awareness when reception is intermittent.
|
|
16
|
+
|
|
17
|
+
## Plugin AIS Target State Management
|
|
18
|
+
The plugin applies standardized timing and continuity rules to manage AIS targets throughout their tracking lifecycle. Targets are created on first reception, transition to a confirmed tracking state after sufficient report continuity, and are marked as lost when expected reports are no longer received. The resulting tracking state is continuously updated and published for use by downstream consumers.
|
|
19
|
+
|
|
20
|
+
### Sources (input scope)
|
|
21
|
+
Subscribes to deltas with the following contexts to determine which entities/updates are tracked:
|
|
22
|
+
- vessels
|
|
23
|
+
- atons
|
|
24
|
+
- shore.basestations
|
|
25
|
+
- sar
|
|
26
|
+
- aircraft
|
|
27
|
+
|
|
28
|
+
### Target identity & indexing (stable identity)
|
|
29
|
+
The Signal K `context` string is the primary key for in-memory tracking. One item per context. No MMSI indexing or conflict handling is performed by the plugin.
|
|
30
|
+
|
|
31
|
+
### Position report rules (message quality)
|
|
32
|
+
Each `navigation.position` update advances the tracking state and updates `lastPosition`.
|
|
33
|
+
|
|
34
|
+
### State thresholds by class (reliability tuning)
|
|
35
|
+
Class A confirms quickly and times out quickly; Class B confirms slower and times out slower, etc. See [Device Class Processing Definition](#device-class-processing-definition) for class details.
|
|
36
|
+
|
|
37
|
+
### Class resolution (source vs fallback)
|
|
38
|
+
The plugin prefers `sensors.ais.class` when it is present and one of the supported values. If it is missing or unsupported, the class falls back to the target context. Unsupported values are logged at debug level.
|
|
39
|
+
|
|
40
|
+
Fallback mapping:
|
|
41
|
+
- `atons.*` -> `ATON`
|
|
42
|
+
- `shore.basestations.*` -> `BASE`
|
|
43
|
+
- `sar.*` -> `SAR`
|
|
44
|
+
- `aircraft.*` -> `AIRCRAFT`
|
|
45
|
+
- otherwise -> `B`
|
|
46
|
+
|
|
47
|
+
### State machine (status consistency)
|
|
48
|
+
*Prior to the first message, status will not exist.*
|
|
49
|
+
- unconfirmed = A position has been received, but the minimum message threshold has not been met.
|
|
50
|
+
- confirmed = The minimum number of messages has been received within the `confirmMaxAge` threshold.
|
|
51
|
+
- lost = No messages have been received within the `lostAfter` threshold.
|
|
52
|
+
- remove = No messages have been received within the `removeAfter` threshold.
|
|
53
|
+
|
|
54
|
+
If not enough messages are received within `confirmMaxAge`, the confirmation process resets and the target remains `unconfirmed`. Once enough messages are received, status becomes `confirmed`. Targets become `lost` after a period of silence and `remove` after a longer period without position.
|
|
55
|
+
|
|
56
|
+
```mermaid
|
|
57
|
+
stateDiagram-v2
|
|
58
|
+
direction LR
|
|
59
|
+
|
|
60
|
+
[*] --> unconfirmed: first position report
|
|
61
|
+
|
|
62
|
+
unconfirmed --> unconfirmed: position report\nmsgNo < confirmAfterMsgs\n(confirmation in progress)
|
|
63
|
+
unconfirmed --> confirmed: position report\nmsgNo ≥ confirmAfterMsgs
|
|
64
|
+
unconfirmed --> unconfirmed: gap > confirmMaxAge\nreset msgCount (msgNo becomes 1)
|
|
65
|
+
|
|
66
|
+
confirmed --> confirmed: position report\nrefresh lastPosition
|
|
67
|
+
|
|
68
|
+
unconfirmed --> lost: timer\nage > lostAfter
|
|
69
|
+
confirmed --> lost: timer\nage > lostAfter
|
|
70
|
+
|
|
71
|
+
lost --> confirmed: position report\n(confirmAfterMsgs = 1)
|
|
72
|
+
lost --> unconfirmed: position report\n(confirmAfterMsgs > 1)
|
|
73
|
+
|
|
74
|
+
unconfirmed --> remove: timer\nage > removeAfter
|
|
75
|
+
confirmed --> remove: timer\nage > removeAfter
|
|
76
|
+
lost --> remove: timer\nage > removeAfter\ndelete target
|
|
77
|
+
|
|
78
|
+
remove --> confirmed: next position report\n(confirmAfterMsgs = 1)\n(new track)
|
|
79
|
+
remove --> unconfirmed: next position report\n(confirmAfterMsgs > 1)\n(new track)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Trigger:** `<context>.navigation.position` received
|
|
83
|
+
| Condition | Action |
|
|
84
|
+
|--- |--- |
|
|
85
|
+
| `msgCount` < `confirmAfterMsgs` | `status` = **'unconfirmed'** <br>`msgCount` -> increment <br>`lastPosition` = `Date.now()` |
|
|
86
|
+
| (`msgCount` >= `confirmAfterMsgs`) <= `confirmMaxAge` | `status` = **'confirmed'** <br>`msgCount` -> capped at `confirmAfterMsgs` <br>`lastPosition` = `Date.now()`
|
|
87
|
+
|
|
88
|
+
If the time gap between unconfirmed messages exceeds `confirmMaxAge`, `msgCount` resets to `0` before the next increment.
|
|
89
|
+
|
|
90
|
+
**Trigger:** Status Interval Timer Event
|
|
91
|
+
| Condition | Action |
|
|
92
|
+
|--- |--- |
|
|
93
|
+
| `Date.now() - lastPosition` > `lostAfter` | `status` = **'lost'** <br>`msgCount` = 0 <br>`lastPosition` -> unchanged |
|
|
94
|
+
| `Date.now() - lastPosition` > `removeAfter` | `status` = **'remove'** <br>`msgCount` = 0 <br>`lastPosition` -> unchanged <br> delete target from map |
|
|
95
|
+
|
|
96
|
+
### Timing & publication (resource consumption)
|
|
97
|
+
The plugin emits Signal K deltas to the target context with:
|
|
98
|
+
|
|
99
|
+
- Path: `sensors.ais.status`
|
|
100
|
+
- Values: `unconfirmed`, `confirmed`, `lost`, `remove`
|
|
101
|
+
|
|
102
|
+
There are two processes that generate delta updates:
|
|
103
|
+
1. Position Update: runs when position is received and applies `unconfirmed` / `confirmed` logic.
|
|
104
|
+
2. Status Check: runs on a fixed interval and applies `lostAfter` and `removeAfter` thresholds.
|
|
105
|
+
|
|
106
|
+
## State Management parameters
|
|
107
|
+
|
|
108
|
+
### Plugin settings
|
|
109
|
+
- `confirmMaxAgeRatio` (number, default `1.1`): Multiplier applied to `confirmMaxAge` for all AIS classes. Example: `1.1` adds a 10% margin before ignoring a message in the confirmation process.
|
|
110
|
+
|
|
111
|
+
### Device Class Processing Definition
|
|
112
|
+
``` typescript
|
|
113
|
+
const AIS_CLASS_DEFAULTS = {
|
|
114
|
+
A: {
|
|
115
|
+
confirmAfterMsgs: 2,
|
|
116
|
+
confirmMaxAge: 180000, // ms
|
|
117
|
+
lostAfter: 360000, // ms
|
|
118
|
+
removeAfter: 540000 // ms
|
|
119
|
+
},
|
|
120
|
+
B: {
|
|
121
|
+
confirmAfterMsgs: 3,
|
|
122
|
+
confirmMaxAge: 180000, // ms
|
|
123
|
+
lostAfter: 360000, // ms
|
|
124
|
+
removeAfter: 540000 // ms
|
|
125
|
+
},
|
|
126
|
+
ATON: {
|
|
127
|
+
confirmAfterMsgs: 1,
|
|
128
|
+
confirmMaxAge: 180000, // ms
|
|
129
|
+
lostAfter: 900000, // ms
|
|
130
|
+
removeAfter: 3600000 // ms
|
|
131
|
+
},
|
|
132
|
+
BASE: {
|
|
133
|
+
confirmAfterMsgs: 1,
|
|
134
|
+
confirmMaxAge: 10000, // ms
|
|
135
|
+
lostAfter: 30000, // ms
|
|
136
|
+
removeAfter: 180000 // ms
|
|
137
|
+
},
|
|
138
|
+
SAR: {
|
|
139
|
+
confirmAfterMsgs: 1,
|
|
140
|
+
confirmMaxAge: 10000, // ms
|
|
141
|
+
lostAfter: 30000, // ms
|
|
142
|
+
removeAfter: 180000 // ms
|
|
143
|
+
},
|
|
144
|
+
AIRCRAFT: {
|
|
145
|
+
confirmAfterMsgs: 1,
|
|
146
|
+
confirmMaxAge: 10000, // ms
|
|
147
|
+
lostAfter: 30000, // ms
|
|
148
|
+
removeAfter: 180000 // ms
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
### Target Selection
|
|
156
|
+
``` typescript
|
|
157
|
+
const AIS_CONTEXT_PREFIXES = [
|
|
158
|
+
'atons.*',
|
|
159
|
+
'shore.basestations.*',
|
|
160
|
+
'vessels.*',
|
|
161
|
+
'sar.*',
|
|
162
|
+
'aircraft.*'
|
|
163
|
+
];
|
|
164
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sk-ais-status-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AIS Status manager plugin for Signal K server.",
|
|
5
|
+
"main": "plugin/index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"signalk-node-server-plugin"
|
|
8
|
+
],
|
|
9
|
+
"repository": "https://github.com/panaaj/sk-ais-status-plugin",
|
|
10
|
+
"author": "AdrianP",
|
|
11
|
+
"license": "Apache-20",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"build-declaration": "tsc --declaration --allowJs false",
|
|
15
|
+
"watch": "npm run build -- -w",
|
|
16
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
17
|
+
"start": "npm run build -- -w",
|
|
18
|
+
"format": "prettier --write src/*",
|
|
19
|
+
"prepublishOnly": "tsc"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@signalk/server-api": "^2.10.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/baconjs": "^0.7.34",
|
|
26
|
+
"@types/express": "^5.0.6",
|
|
27
|
+
"prettier": "^2.5.1",
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
|
+
},
|
|
30
|
+
"signalk-plugin-enabled-by-default": true
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/plugin/index.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// **** Signal K flags resources ****
|
|
4
|
+
const server_api_1 = require("@signalk/server-api");
|
|
5
|
+
var AIS_STATUS;
|
|
6
|
+
(function (AIS_STATUS) {
|
|
7
|
+
AIS_STATUS["unconfirmed"] = "unconfirmed";
|
|
8
|
+
AIS_STATUS["confirmed"] = "confirmed";
|
|
9
|
+
AIS_STATUS["lost"] = "lost";
|
|
10
|
+
AIS_STATUS["remove"] = "remove";
|
|
11
|
+
})(AIS_STATUS || (AIS_STATUS = {}));
|
|
12
|
+
const AIS_CLASS_DEFAULTS = {
|
|
13
|
+
A: {
|
|
14
|
+
confirmAfterMsgs: 2,
|
|
15
|
+
confirmMaxAge: 3 * 60000, // 3 min when moored, < 10 sec when moving)
|
|
16
|
+
lostAfter: 6 * 60000,
|
|
17
|
+
removeAfter: 9 * 60000
|
|
18
|
+
},
|
|
19
|
+
B: {
|
|
20
|
+
confirmAfterMsgs: 3,
|
|
21
|
+
confirmMaxAge: 3 * 60000, // 3 min when moored, < 30 sec when moving)
|
|
22
|
+
lostAfter: 6 * 60000,
|
|
23
|
+
removeAfter: 9 * 60000
|
|
24
|
+
},
|
|
25
|
+
ATON: {
|
|
26
|
+
confirmAfterMsgs: 1,
|
|
27
|
+
confirmMaxAge: 3 * 60000, // 3 min nominal
|
|
28
|
+
lostAfter: 15 * 60000, // 15 min = timeout / loss
|
|
29
|
+
removeAfter: 60 * 60000
|
|
30
|
+
},
|
|
31
|
+
BASE: {
|
|
32
|
+
confirmAfterMsgs: 1,
|
|
33
|
+
confirmMaxAge: 10000, // 10 sec nominal
|
|
34
|
+
lostAfter: 30000,
|
|
35
|
+
removeAfter: 3 * 60000
|
|
36
|
+
},
|
|
37
|
+
SAR: {
|
|
38
|
+
confirmAfterMsgs: 1,
|
|
39
|
+
confirmMaxAge: 10000, // 10 sec nominal
|
|
40
|
+
lostAfter: 30000,
|
|
41
|
+
removeAfter: 3 * 60000
|
|
42
|
+
},
|
|
43
|
+
AIRCRAFT: {
|
|
44
|
+
confirmAfterMsgs: 1,
|
|
45
|
+
confirmMaxAge: 10000, // treat similarly to SAR/BASE (fast turnover)
|
|
46
|
+
lostAfter: 30000,
|
|
47
|
+
removeAfter: 3 * 60000
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const isAISClass = (value) => typeof value === 'string' &&
|
|
51
|
+
['A', 'B', 'ATON', 'BASE', 'SAR', 'AIRCRAFT'].includes(value);
|
|
52
|
+
const classFromContext = (context) => {
|
|
53
|
+
if (context.startsWith('atons.'))
|
|
54
|
+
return 'ATON';
|
|
55
|
+
if (context.startsWith('shore.basestations.'))
|
|
56
|
+
return 'BASE';
|
|
57
|
+
if (context.startsWith('sar.'))
|
|
58
|
+
return 'SAR';
|
|
59
|
+
if (context.startsWith('aircraft.'))
|
|
60
|
+
return 'AIRCRAFT';
|
|
61
|
+
return 'B';
|
|
62
|
+
};
|
|
63
|
+
const STATUS_CHECK_INTERVAL = 5000;
|
|
64
|
+
const CONFIG_SCHEMA = {
|
|
65
|
+
properties: {
|
|
66
|
+
confirmMaxAgeRatio: {
|
|
67
|
+
type: 'number',
|
|
68
|
+
title: 'Confirmation max age margin',
|
|
69
|
+
description: 'Multiplier applied to the maximum message age threshold of the target confirmation process (e.g., 1.1 = +10%). Applied to all AIS classes.',
|
|
70
|
+
default: 1.1,
|
|
71
|
+
minimum: 0.1
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const CONFIG_UISCHEMA = {};
|
|
76
|
+
const DEFAULT_SETTINGS = {
|
|
77
|
+
confirmMaxAgeRatio: 1.1
|
|
78
|
+
};
|
|
79
|
+
module.exports = (server) => {
|
|
80
|
+
let subscriptions = []; // stream subscriptions
|
|
81
|
+
let timers = []; // interval timers
|
|
82
|
+
const plugin = {
|
|
83
|
+
id: 'sk-ais-status',
|
|
84
|
+
name: 'AIS Status Manager',
|
|
85
|
+
schema: () => CONFIG_SCHEMA,
|
|
86
|
+
uiSchema: () => CONFIG_UISCHEMA,
|
|
87
|
+
start: (options, restart) => {
|
|
88
|
+
doStartup(options);
|
|
89
|
+
},
|
|
90
|
+
stop: () => {
|
|
91
|
+
doShutdown();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
let settings = { ...DEFAULT_SETTINGS };
|
|
95
|
+
let self = '';
|
|
96
|
+
let targets = new Map();
|
|
97
|
+
const doStartup = (options) => {
|
|
98
|
+
try {
|
|
99
|
+
server.debug(`${plugin.name} starting.......`);
|
|
100
|
+
if (options) {
|
|
101
|
+
settings = { ...DEFAULT_SETTINGS, ...options };
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// save defaults if no options loaded
|
|
105
|
+
server.savePluginOptions(settings, () => {
|
|
106
|
+
server.debug(`Default configuration applied...`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
server.debug(`Applied configuration: ${JSON.stringify(settings)}`);
|
|
110
|
+
server.setPluginStatus(`Started`);
|
|
111
|
+
// initialize plugin
|
|
112
|
+
initialize();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const msg = `Started with errors!`;
|
|
116
|
+
server.setPluginError(msg);
|
|
117
|
+
server.error('error: ' + error);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const doShutdown = () => {
|
|
121
|
+
server.debug(`${plugin.name} stopping.......`);
|
|
122
|
+
server.debug('** Un-registering Update Handler(s) **');
|
|
123
|
+
subscriptions.forEach((b) => b());
|
|
124
|
+
subscriptions = [];
|
|
125
|
+
server.debug('** Stopping Timer(s) **');
|
|
126
|
+
timers.forEach((t) => clearInterval(t));
|
|
127
|
+
timers = [];
|
|
128
|
+
const msg = 'Stopped.';
|
|
129
|
+
server.setPluginStatus(msg);
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* initialize plugin
|
|
133
|
+
*/
|
|
134
|
+
const initialize = () => {
|
|
135
|
+
server.debug('Initializing ....');
|
|
136
|
+
// setup subscriptions
|
|
137
|
+
initSubscriptions();
|
|
138
|
+
self = server.getPath('self');
|
|
139
|
+
timers.push(setInterval(() => checkStatus(), STATUS_CHECK_INTERVAL));
|
|
140
|
+
};
|
|
141
|
+
// register DELTA stream message handler
|
|
142
|
+
const initSubscriptions = () => {
|
|
143
|
+
const subDef = [
|
|
144
|
+
{
|
|
145
|
+
path: 'navigation.position',
|
|
146
|
+
period: 500
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
const subs = [
|
|
150
|
+
{
|
|
151
|
+
context: 'vessels.*',
|
|
152
|
+
subscribe: subDef
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
context: 'atons.*',
|
|
156
|
+
subscribe: subDef
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
context: 'shore.basestations.*',
|
|
160
|
+
subscribe: subDef
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
context: 'sar.*',
|
|
164
|
+
subscribe: subDef
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
context: 'aircraft.*',
|
|
168
|
+
subscribe: subDef
|
|
169
|
+
}
|
|
170
|
+
];
|
|
171
|
+
server.debug(`Subscribing to contexts: ${subs.map((s) => s.context).join(', ')}`);
|
|
172
|
+
server.debug(`With paths: ${subDef
|
|
173
|
+
.map((s) => `${s.path} (${s.period} ms)`)
|
|
174
|
+
.join(', ')}`);
|
|
175
|
+
subs.forEach((s) => {
|
|
176
|
+
server.subscriptionmanager.subscribe(s, subscriptions, onError, onMessage);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Delta message handler
|
|
181
|
+
* @param delta Delta message
|
|
182
|
+
*/
|
|
183
|
+
const onMessage = (delta) => {
|
|
184
|
+
if (!delta.updates) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (delta.context === self) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
delta.updates.forEach((u) => {
|
|
191
|
+
if (!(0, server_api_1.hasValues)(u)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
u.values.forEach((v) => {
|
|
195
|
+
if (v.path === 'navigation.position') {
|
|
196
|
+
if (!targets.has(delta.context)) {
|
|
197
|
+
targets.set(delta.context, {
|
|
198
|
+
lastPosition: 0,
|
|
199
|
+
msgCount: 0
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
processTarget(delta.context);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
/** Handle subscription error */
|
|
208
|
+
const onError = (error) => {
|
|
209
|
+
server.error(`${plugin.id} Error: ${error}`);
|
|
210
|
+
};
|
|
211
|
+
/** Process target after position message */
|
|
212
|
+
const processTarget = (context) => {
|
|
213
|
+
const target = targets.get(context);
|
|
214
|
+
if (!target)
|
|
215
|
+
return;
|
|
216
|
+
const aisClass = getAisClass(context);
|
|
217
|
+
const confirmMaxAge = getConfirmMaxAge(aisClass);
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (target.msgCount > 0 &&
|
|
220
|
+
target.msgCount < AIS_CLASS_DEFAULTS[aisClass].confirmAfterMsgs) {
|
|
221
|
+
const elapse = now - target.lastPosition;
|
|
222
|
+
if (elapse > confirmMaxAge) {
|
|
223
|
+
server.debug(`*** Confirm max age exceeded (${elapse} ms > ${confirmMaxAge} ms) -> reset confirmation`, context, aisClass);
|
|
224
|
+
target.msgCount = 0;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const msgNo = target.msgCount + 1;
|
|
228
|
+
target.lastPosition = now;
|
|
229
|
+
// confirmMsg threshold met?
|
|
230
|
+
if (msgNo < AIS_CLASS_DEFAULTS[aisClass].confirmAfterMsgs) {
|
|
231
|
+
target.msgCount = msgNo;
|
|
232
|
+
if (emitAisStatus(context, AIS_STATUS.unconfirmed)) {
|
|
233
|
+
server.debug(`*** Threshold not met (${msgNo}/${AIS_CLASS_DEFAULTS[aisClass].confirmAfterMsgs}) -> unconfirmed`, context, aisClass);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
target.msgCount = AIS_CLASS_DEFAULTS[aisClass].confirmAfterMsgs;
|
|
238
|
+
if (emitAisStatus(context, AIS_STATUS.confirmed)) {
|
|
239
|
+
server.debug(`*** Threshold met (${msgNo}/${AIS_CLASS_DEFAULTS[aisClass].confirmAfterMsgs}) -> confirmed`, context, aisClass);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
targets.set(context, target);
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Return AIS Class of supplied Context
|
|
246
|
+
* @param context Signal K context
|
|
247
|
+
* @returns AIS class (falls back to context-derived class)
|
|
248
|
+
*/
|
|
249
|
+
const getAisClass = (context) => {
|
|
250
|
+
const aisClass = server.getPath(`${context}.sensors.ais.class`)?.value;
|
|
251
|
+
if (isAISClass(aisClass)) {
|
|
252
|
+
return aisClass;
|
|
253
|
+
}
|
|
254
|
+
return classFromContext(context);
|
|
255
|
+
};
|
|
256
|
+
const getConfirmMaxAge = (aisClass) => {
|
|
257
|
+
const ratio = Number(settings.confirmMaxAgeRatio);
|
|
258
|
+
if (!Number.isFinite(ratio) || ratio <= 0) {
|
|
259
|
+
return AIS_CLASS_DEFAULTS[aisClass].confirmMaxAge;
|
|
260
|
+
}
|
|
261
|
+
return Math.round(AIS_CLASS_DEFAULTS[aisClass].confirmMaxAge * ratio);
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Check and update AIS target(s) status
|
|
265
|
+
*/
|
|
266
|
+
const checkStatus = () => {
|
|
267
|
+
targets.forEach((v, k) => {
|
|
268
|
+
const aisClass = getAisClass(k);
|
|
269
|
+
const tDiff = Date.now() - v.lastPosition;
|
|
270
|
+
if (tDiff >= AIS_CLASS_DEFAULTS[aisClass].removeAfter) {
|
|
271
|
+
if (emitAisStatus(k, AIS_STATUS.remove)) {
|
|
272
|
+
server.debug('*** Remove threshold met -> remove', k, aisClass);
|
|
273
|
+
}
|
|
274
|
+
targets.delete(k);
|
|
275
|
+
}
|
|
276
|
+
else if (tDiff >= AIS_CLASS_DEFAULTS[aisClass].lostAfter) {
|
|
277
|
+
v.msgCount = 0;
|
|
278
|
+
if (emitAisStatus(k, AIS_STATUS.lost)) {
|
|
279
|
+
server.debug('*** Lost threshold met -> lost', k, aisClass);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Emits sensors.ais.status delta
|
|
286
|
+
* @param context Signal K context
|
|
287
|
+
*/
|
|
288
|
+
const emitAisStatus = (context, status) => {
|
|
289
|
+
let currStatus = server.getPath(`${context}.sensors.ais.status`);
|
|
290
|
+
if (status === currStatus?.value) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
server.handleMessage(plugin.id, {
|
|
294
|
+
context: context,
|
|
295
|
+
updates: [
|
|
296
|
+
{
|
|
297
|
+
values: [
|
|
298
|
+
{
|
|
299
|
+
path: 'sensors.ais.status',
|
|
300
|
+
value: status
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
}, server_api_1.SKVersion.v1);
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
return plugin;
|
|
309
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.0",
|
|
3
|
+
"info": {
|
|
4
|
+
"version": "2.5.0",
|
|
5
|
+
"title": "Signal K Flags API",
|
|
6
|
+
"description": "API endpoints exposed by `signalk-flags` plugin for displaying flags based on MMSI or country code.",
|
|
7
|
+
"termsOfService": "http://signalk.org/terms/",
|
|
8
|
+
"license": {
|
|
9
|
+
"name": "Apache 2.0",
|
|
10
|
+
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"externalDocs": {
|
|
14
|
+
"url": "http://signalk.org/specification/",
|
|
15
|
+
"description": "Signal K specification."
|
|
16
|
+
},
|
|
17
|
+
"servers": [
|
|
18
|
+
{
|
|
19
|
+
"url": "/"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"tags": [
|
|
23
|
+
{
|
|
24
|
+
"name": "Flags",
|
|
25
|
+
"description": "Endpoints for retrieving country flags."
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"components": {
|
|
29
|
+
"schemas": {},
|
|
30
|
+
"responses": {
|
|
31
|
+
"200OKResponse": {
|
|
32
|
+
"description": "Successful operation",
|
|
33
|
+
"content": {
|
|
34
|
+
"application/json": {
|
|
35
|
+
"schema": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "Request success response",
|
|
38
|
+
"properties": {
|
|
39
|
+
"state": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"enum": ["COMPLETED"]
|
|
42
|
+
},
|
|
43
|
+
"statusCode": {
|
|
44
|
+
"type": "number",
|
|
45
|
+
"enum": [200]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": ["state", "statusCode"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"ErrorResponse": {
|
|
54
|
+
"description": "Failed operation",
|
|
55
|
+
"content": {
|
|
56
|
+
"application/json": {
|
|
57
|
+
"schema": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"description": "Request error response",
|
|
60
|
+
"properties": {
|
|
61
|
+
"state": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": ["FAILED"]
|
|
64
|
+
},
|
|
65
|
+
"statusCode": {
|
|
66
|
+
"type": "number",
|
|
67
|
+
"enum": [404]
|
|
68
|
+
},
|
|
69
|
+
"message": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"required": ["state", "statusCode", "message"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"parameters": {
|
|
80
|
+
"mmsi": {
|
|
81
|
+
"in": "path",
|
|
82
|
+
"required": true,
|
|
83
|
+
"name": "mmsi",
|
|
84
|
+
"description": "Maritime Mobile Service Identity (MMSI).",
|
|
85
|
+
"schema": {
|
|
86
|
+
"type": "number",
|
|
87
|
+
"minLength": 9,
|
|
88
|
+
"maxLength": 9,
|
|
89
|
+
"example": 520345561
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"countryCode": {
|
|
93
|
+
"in": "path",
|
|
94
|
+
"required": true,
|
|
95
|
+
"name": "code",
|
|
96
|
+
"description": "Two letter country code.",
|
|
97
|
+
"schema": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"minLength": 2,
|
|
100
|
+
"maxLength": 2,
|
|
101
|
+
"example": "al"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"aspect": {
|
|
105
|
+
"in": "query",
|
|
106
|
+
"required": false,
|
|
107
|
+
"name": "aspect",
|
|
108
|
+
"description": "Aspect of flag image to return. 4x3 (default)",
|
|
109
|
+
"schema": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"enum": ["4x3", "1x1"]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"securitySchemes": {
|
|
116
|
+
"bearerAuth": {
|
|
117
|
+
"type": "http",
|
|
118
|
+
"scheme": "bearer",
|
|
119
|
+
"bearerFormat": "JWT"
|
|
120
|
+
},
|
|
121
|
+
"cookieAuth": {
|
|
122
|
+
"type": "apiKey",
|
|
123
|
+
"in": "cookie",
|
|
124
|
+
"name": "JAUTHENTICATION"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"security": [{ "cookieAuth": [] }, { "bearerAuth": [] }],
|
|
129
|
+
"paths": {
|
|
130
|
+
"/signalk/v2/api/resources/flags/mmsi/{mmsi}": {
|
|
131
|
+
"parameters": [
|
|
132
|
+
{
|
|
133
|
+
"$ref": "#/components/parameters/mmsi"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"$ref": "#/components/parameters/aspect"
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
"get": {
|
|
140
|
+
"tags": ["Flags"],
|
|
141
|
+
"summary": "Returns flag for supplied MMSI.",
|
|
142
|
+
"responses": {
|
|
143
|
+
"default": {
|
|
144
|
+
"description": "Return SVG content for flag derived from the supplied MMSI.",
|
|
145
|
+
"content": {
|
|
146
|
+
"image/svg+xml": {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"/signalk/v2/api/resources/flags/country/{code}": {
|
|
153
|
+
"parameters": [
|
|
154
|
+
{
|
|
155
|
+
"$ref": "#/components/parameters/countryCode"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"$ref": "#/components/parameters/aspect"
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"get": {
|
|
162
|
+
"tags": ["Flags"],
|
|
163
|
+
"summary": "Returns flag for supplied country code.",
|
|
164
|
+
"responses": {
|
|
165
|
+
"default": {
|
|
166
|
+
"description": "Return SVG content for flag derived from the supplied country code.",
|
|
167
|
+
"content": {
|
|
168
|
+
"image/svg+xml": {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|