kahu-signalk 0.0.13 → 0.0.16
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/.cursor/plans/3-branch_implementation_split_b745065d.plan.md +150 -0
- package/.gitmodules +1 -0
- package/README.md +61 -3
- package/data/protocol/migrations/0003-add-datakind.sql +2 -0
- package/kahu-signalk-0.0.1.tgz +0 -0
- package/package.json +3 -2
- package/plugin/connector.js +17 -1
- package/plugin/index.js +108 -1
- package/plugin/mayara.js +205 -0
- package/plugin/routecache.js +12 -4
- package/radarhub-signalk-0.0.3.tgz +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 3-branch implementation split
|
|
3
|
+
overview: Split the Mayara + own-ship + data classification work into three sequential branches with explicit interfaces and no unknown cross-repo dependencies.
|
|
4
|
+
todos:
|
|
5
|
+
- id: branch1-classification
|
|
6
|
+
content: Finalize Branch 1 scope and acceptance criteria for classification foundation
|
|
7
|
+
status: completed
|
|
8
|
+
- id: branch2-ownship
|
|
9
|
+
content: Finalize Branch 2 scope and acceptance criteria for own-ship route tracking
|
|
10
|
+
status: completed
|
|
11
|
+
- id: branch3-mayara
|
|
12
|
+
content: Finalize Branch 3 scope and acceptance criteria for Mayara ingestion
|
|
13
|
+
status: completed
|
|
14
|
+
isProject: false
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# 3-Branch Implementation Plan
|
|
18
|
+
|
|
19
|
+
## Strategy
|
|
20
|
+
|
|
21
|
+
Implement in dependency order so each branch is mergeable by itself:
|
|
22
|
+
1. **Branch 1 = storage and metadata foundation**
|
|
23
|
+
2. **Branch 2 = own-ship route tracking**
|
|
24
|
+
3. **Branch 3 = Mayara ingestion**
|
|
25
|
+
|
|
26
|
+
No branch requires protocol repo (`radarhub-protocol`) or server changes to land. Avro schema changes remain a separate follow-up.
|
|
27
|
+
|
|
28
|
+
## Branch 1 — Data Classification Foundation
|
|
29
|
+
|
|
30
|
+
### Goal
|
|
31
|
+
Add stable data classification (`data_kind`, `source_type`) and RATTM Signal K metadata without changing ingestion behavior.
|
|
32
|
+
|
|
33
|
+
### Scope
|
|
34
|
+
- Update DB migration to add `data_kind` + `source_type`
|
|
35
|
+
- Update `Routecache.insert()` to store those columns with defaults
|
|
36
|
+
- Tag RATTM inserts as `arpaTarget` / `RATTM`
|
|
37
|
+
- Extend RATTM Signal K delta with `kahu.dataKind`, `kahu.source`, `kahu.target.id`, `kahu.target.status`
|
|
38
|
+
- README: add a short section on data classification semantics
|
|
39
|
+
|
|
40
|
+
### Files
|
|
41
|
+
- [plugin/routecache.js](/home/bs01743/Projects/KAHU/radarhub-signalk/plugin/routecache.js)
|
|
42
|
+
- [plugin/index.js](/home/bs01743/Projects/KAHU/radarhub-signalk/plugin/index.js)
|
|
43
|
+
- [data/protocol/migrations/0003-add-datakind.sql](/home/bs01743/Projects/KAHU/radarhub-signalk/data/protocol/migrations/0003-add-datakind.sql)
|
|
44
|
+
- [README.md](/home/bs01743/Projects/KAHU/radarhub-signalk/README.md)
|
|
45
|
+
|
|
46
|
+
### Branch Output Contract
|
|
47
|
+
- Existing RATTM flow unchanged functionally
|
|
48
|
+
- Every cached RATTM point has explicit classification fields
|
|
49
|
+
- Live Signal K consumers can distinguish radar contacts via `kahu.dataKind`
|
|
50
|
+
|
|
51
|
+
### Dependency Notes
|
|
52
|
+
- No dependency on Mayara
|
|
53
|
+
- No dependency on own-ship tracking
|
|
54
|
+
- No dependency on protocol/server migration
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Branch 2 — Own-Ship Route Tracking
|
|
59
|
+
|
|
60
|
+
### Goal
|
|
61
|
+
Track and upload vessel route explicitly from Signal K self position stream.
|
|
62
|
+
|
|
63
|
+
### Scope
|
|
64
|
+
- Add plugin config section `own_ship_tracking` with:
|
|
65
|
+
- `enabled` (default `true`)
|
|
66
|
+
- `sample_interval_s` (default `10`)
|
|
67
|
+
- Subscribe to `navigation.position` stream for self-context, non-TTM updates
|
|
68
|
+
- Sample/throttle inserts by config interval
|
|
69
|
+
- Insert own-ship points using:
|
|
70
|
+
- `data_kind = vesselRoute`
|
|
71
|
+
- `source_type = gps`
|
|
72
|
+
- Ensure lifecycle cleanup (`unsubscribe` on stop)
|
|
73
|
+
- README: document own-ship behavior and dashboard meaning (blue route)
|
|
74
|
+
|
|
75
|
+
### Files
|
|
76
|
+
- [plugin/index.js](/home/bs01743/Projects/KAHU/radarhub-signalk/plugin/index.js)
|
|
77
|
+
- [README.md](/home/bs01743/Projects/KAHU/radarhub-signalk/README.md)
|
|
78
|
+
|
|
79
|
+
### Branch Output Contract
|
|
80
|
+
- Vessel route is recorded even when no ARPA targets exist
|
|
81
|
+
- Data classified distinctly from ARPA targets
|
|
82
|
+
- Connector upload path works unchanged (same route point shape)
|
|
83
|
+
|
|
84
|
+
### Dependency Notes
|
|
85
|
+
- Depends on Branch 1 classification columns/defaults
|
|
86
|
+
- Does not depend on Mayara branch
|
|
87
|
+
- Still no protocol/server schema dependency
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Branch 3 — Mayara Optional Ingestion
|
|
92
|
+
|
|
93
|
+
### Goal
|
|
94
|
+
Add optional Mayara target ingestion (WS primary, HTTP fallback) into existing cache/upload pipeline.
|
|
95
|
+
|
|
96
|
+
### Scope
|
|
97
|
+
- Add dependency `ws` to package.json
|
|
98
|
+
- Add plugin config section `mayara`:
|
|
99
|
+
- `enabled` (default `false`)
|
|
100
|
+
- `base_url` (default `http://localhost:6502`)
|
|
101
|
+
- `poll_interval_ms` (default `2000`)
|
|
102
|
+
- Create `MayaraIngestor` module
|
|
103
|
+
- Radar discovery from REST API
|
|
104
|
+
- WS subscribe to `radars.*.targets.*`
|
|
105
|
+
- Parse/normalize fields and units
|
|
106
|
+
- Filter status to `tracking`
|
|
107
|
+
- Stable UUID mapping per radar+target
|
|
108
|
+
- HTTP polling fallback with reconnect strategy
|
|
109
|
+
- Cache inserts as:
|
|
110
|
+
- `data_kind = arpaTarget`
|
|
111
|
+
- `source_type = mayara`
|
|
112
|
+
- Lifecycle wiring in plugin start/stop
|
|
113
|
+
- README: operational setup and troubleshooting for Mayara
|
|
114
|
+
|
|
115
|
+
### Files
|
|
116
|
+
- [package.json](/home/bs01743/Projects/KAHU/radarhub-signalk/package.json)
|
|
117
|
+
- [plugin/mayara.js](/home/bs01743/Projects/KAHU/radarhub-signalk/plugin/mayara.js)
|
|
118
|
+
- [plugin/index.js](/home/bs01743/Projects/KAHU/radarhub-signalk/plugin/index.js)
|
|
119
|
+
- [README.md](/home/bs01743/Projects/KAHU/radarhub-signalk/README.md)
|
|
120
|
+
|
|
121
|
+
### Branch Output Contract
|
|
122
|
+
- Mayara can be enabled without affecting RATTM or own-ship flows
|
|
123
|
+
- If Mayara is unavailable, plugin degrades gracefully and existing flows continue
|
|
124
|
+
- All Mayara target points are explicitly classified
|
|
125
|
+
|
|
126
|
+
### Dependency Notes
|
|
127
|
+
- Depends on Branch 1 classification fields
|
|
128
|
+
- Independent from Branch 2 runtime behavior
|
|
129
|
+
- No protocol/server migration dependency
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Cross-Branch Guardrails
|
|
134
|
+
|
|
135
|
+
- Keep Avro schema unchanged in all 3 branches
|
|
136
|
+
- Keep server-side kind/source ingestion as separate follow-up project
|
|
137
|
+
- Preserve backward compatibility with existing local DB rows (defaults)
|
|
138
|
+
- Keep each PR testable with local SQLite checks + plugin logs
|
|
139
|
+
|
|
140
|
+
## Suggested Branch Names
|
|
141
|
+
|
|
142
|
+
- `feature/classification-foundation`
|
|
143
|
+
- `feature/own-ship-route-tracking`
|
|
144
|
+
- `feature/mayara-ingestion`
|
|
145
|
+
|
|
146
|
+
## Merge Order
|
|
147
|
+
|
|
148
|
+
1. Branch 1
|
|
149
|
+
2. Branch 2 (rebased on Branch 1)
|
|
150
|
+
3. Branch 3 (rebased on Branch 1 or Branch 2; preferred on Branch 2 so README and config context are unified)
|
package/.gitmodules
CHANGED
package/README.md
CHANGED
|
@@ -96,6 +96,23 @@ A `$RATTM` sentence contains:
|
|
|
96
96
|
|
|
97
97
|
---
|
|
98
98
|
|
|
99
|
+
## Data Classification
|
|
100
|
+
|
|
101
|
+
The plugin stores classification metadata for each cached point so downstream
|
|
102
|
+
consumers can reliably distinguish track types.
|
|
103
|
+
|
|
104
|
+
| Field | Meaning | Current values |
|
|
105
|
+
|-------|---------|----------------|
|
|
106
|
+
| `data_kind` | Semantic category of the data point | `arpaTarget`, `vesselRoute` |
|
|
107
|
+
| `source_type` | Origin of the point | `RATTM`, `mayara`, `gps` |
|
|
108
|
+
|
|
109
|
+
For RATTM-derived targets, Signal K deltas also include:
|
|
110
|
+
- `kahu.dataKind = arpaTarget`
|
|
111
|
+
- `kahu.source = RATTM`
|
|
112
|
+
- `kahu.target.id` and `kahu.target.status`
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
99
116
|
## Installation
|
|
100
117
|
|
|
101
118
|
```bash
|
|
@@ -129,6 +146,11 @@ Enable the plugin in the Signal K admin UI under **Server > Plugin Config > KAHU
|
|
|
129
146
|
| `api_key` | *(none)* | Your API key for authentication |
|
|
130
147
|
| `min_reconnect_time` | `100` | Minimum delay (ms) before reconnecting after a drop |
|
|
131
148
|
| `max_reconnect_time` | `600` | Maximum delay (ms) between reconnection attempts |
|
|
149
|
+
| `own_ship_tracking.enabled` | `true` | Enable own-ship GPS route tracking |
|
|
150
|
+
| `own_ship_tracking.sample_interval_s` | `10` | Minimum seconds between own-ship route points |
|
|
151
|
+
| `mayara.enabled` | `false` | Enable Mayara target ingestion |
|
|
152
|
+
| `mayara.base_url` | `http://localhost:6502` | Base URL for Mayara server |
|
|
153
|
+
| `mayara.poll_interval_ms` | `2000` | HTTP fallback polling interval when WS is unavailable |
|
|
132
154
|
|
|
133
155
|
### Prerequisites
|
|
134
156
|
|
|
@@ -138,9 +160,43 @@ Enable the plugin in the Signal K admin UI under **Server > Plugin Config > KAHU
|
|
|
138
160
|
|
|
139
161
|
---
|
|
140
162
|
|
|
163
|
+
## Mayara Integration (Optional)
|
|
164
|
+
|
|
165
|
+
If you run [mayara-server](https://github.com/MarineYachtRadar/mayara-server),
|
|
166
|
+
this plugin can ingest ARPA targets from Mayara in addition to `$RATTM`.
|
|
167
|
+
|
|
168
|
+
How it works:
|
|
169
|
+
1. Discover radars from `GET /signalk/v2/api/vessels/self/radars`
|
|
170
|
+
2. Connect to `/signalk/v1/stream` and subscribe to `radars.*.targets.*`
|
|
171
|
+
3. Ingest only `tracking` targets
|
|
172
|
+
4. Write to cache as `data_kind=arpaTarget`, `source_type=mayara`
|
|
173
|
+
5. If WebSocket is unstable, fall back to periodic HTTP polling at
|
|
174
|
+
`/signalk/v2/api/vessels/self/radars/{id}/targets`
|
|
175
|
+
|
|
176
|
+
This path is optional and does not affect own-ship tracking or existing RATTM
|
|
177
|
+
parsing when disabled.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Own-Ship Route Tracking
|
|
182
|
+
|
|
183
|
+
The plugin now tracks your vessel route explicitly from Signal K
|
|
184
|
+
`navigation.position` updates (self-context, non-TTM source). These points are
|
|
185
|
+
stored with:
|
|
186
|
+
|
|
187
|
+
- `data_kind = vesselRoute`
|
|
188
|
+
- `source_type = gps`
|
|
189
|
+
|
|
190
|
+
This route stream is independent from ARPA targets, so route points continue to
|
|
191
|
+
be recorded even when there are no tracked radar contacts. In dashboards, this
|
|
192
|
+
is intended to be rendered as the vessel route (typically blue), while ARPA
|
|
193
|
+
targets remain separate (typically red).
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
141
197
|
## Current Limitations
|
|
142
198
|
|
|
143
|
-
-
|
|
199
|
+
- `$RATTL` target list sentences are not parsed
|
|
144
200
|
- Does not collect AIS data, only radar ARPA targets
|
|
145
201
|
- The protocol is **NOT encrypted** (data is sent in plain text)
|
|
146
202
|
- The protocol is **NOT cryptographically signed** (no tamper protection)
|
|
@@ -161,12 +217,14 @@ radarhub-signalk/
|
|
|
161
217
|
│ ├── tcpclient.js # TCP socket client with auto-reconnect
|
|
162
218
|
│ ├── avroclient.js # Avro serialization/deserialization over TCP
|
|
163
219
|
│ ├── routecache.js # SQLite local cache for track points
|
|
220
|
+
│ ├── mayara.js # Optional Mayara target ingestion
|
|
164
221
|
│ └── utils.js # Utility helpers
|
|
165
222
|
└── data/protocol/ # Git submodule (radarhub-protocol)
|
|
166
223
|
├── proto_avro.json # Avro schema defining the wire protocol
|
|
167
224
|
└── migrations/ # SQLite database migrations
|
|
168
225
|
├── 0001-create-targets.sql
|
|
169
|
-
|
|
226
|
+
├── 0002-target-indices.sql
|
|
227
|
+
└── 0003-add-datakind.sql
|
|
170
228
|
```
|
|
171
229
|
|
|
172
230
|
---
|
|
@@ -186,7 +244,7 @@ wanting to build a more elaborate server-side setup.
|
|
|
186
244
|
|
|
187
245
|
- **Signal K Server:** v2.22.1+ (latest stable)
|
|
188
246
|
- **Node.js:** **22.5.0+** (built-in `node:sqlite`; no native `sqlite3` bindings).
|
|
189
|
-
- **Dependencies:** avro-js, promise-socket, uuid
|
|
247
|
+
- **Dependencies:** avro-js, promise-socket, uuid, ws
|
|
190
248
|
|
|
191
249
|
---
|
|
192
250
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kahu-signalk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Contribute AIS and ARPA targets from your vessel to crowdsourcing for marine safety!",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"signalk-node-server-plugin",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"avro-js": "^1.12.0",
|
|
32
32
|
"promise-socket": "^8.0.0",
|
|
33
|
-
"uuid": "^8.1.0"
|
|
33
|
+
"uuid": "^8.1.0",
|
|
34
|
+
"ws": "^8.18.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/plugin/connector.js
CHANGED
|
@@ -119,7 +119,23 @@ class Connector {
|
|
|
119
119
|
async sendTracks() {
|
|
120
120
|
const submit = await this.routecache.retrieve();
|
|
121
121
|
if (submit === null) return;
|
|
122
|
-
|
|
122
|
+
|
|
123
|
+
const pointCount = Array.isArray(submit.route) ? submit.route.length : 0;
|
|
124
|
+
const firstPoint = pointCount > 0 ? submit.route[0] : null;
|
|
125
|
+
const lastPoint = pointCount > 0 ? submit.route[pointCount - 1] : null;
|
|
126
|
+
console.error(
|
|
127
|
+
"Connector submitting route:",
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
uuid: submit?.uuid?.string,
|
|
130
|
+
data_kind: submit?.data_kind,
|
|
131
|
+
source_type: submit?.source_type,
|
|
132
|
+
start_epoch_ms: submit?.start,
|
|
133
|
+
point_count: pointCount,
|
|
134
|
+
first_point: firstPoint,
|
|
135
|
+
last_point: lastPoint,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
123
139
|
await this.client.send({
|
|
124
140
|
Message: {
|
|
125
141
|
"kahu.Call": {
|
package/plugin/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { v4: uuidv4 } = require("uuid");
|
|
2
2
|
const { Routecache } = require("./routecache");
|
|
3
3
|
const { Connector } = require("./connector");
|
|
4
|
+
const { MayaraIngestor } = require("./mayara");
|
|
4
5
|
const path = require("path");
|
|
5
6
|
const fs = require("fs");
|
|
6
7
|
|
|
@@ -76,6 +77,8 @@ const polar2Pos = (ownPos, bearing, distance) => {
|
|
|
76
77
|
|
|
77
78
|
const buildRattmDelta = ({
|
|
78
79
|
route_id,
|
|
80
|
+
target_id,
|
|
81
|
+
target_status,
|
|
79
82
|
target_distance,
|
|
80
83
|
target_bearing,
|
|
81
84
|
target_bearing_unit,
|
|
@@ -103,6 +106,10 @@ const buildRattmDelta = ({
|
|
|
103
106
|
{ path: "navigation.speedOverGround", value: target_speed },
|
|
104
107
|
{ path: "navigation.courseOverGroundTrue", value: target_course },
|
|
105
108
|
{ path: "navigation.position", value: { ...targetPos, relative } },
|
|
109
|
+
{ path: "kahu.dataKind", value: "arpaTarget" },
|
|
110
|
+
{ path: "kahu.source", value: "RATTM" },
|
|
111
|
+
{ path: "kahu.target.id", value: target_id },
|
|
112
|
+
{ path: "kahu.target.status", value: target_status || null },
|
|
106
113
|
],
|
|
107
114
|
},
|
|
108
115
|
],
|
|
@@ -118,6 +125,9 @@ module.exports = (app) => {
|
|
|
118
125
|
|
|
119
126
|
plugin.pending_rattm = [];
|
|
120
127
|
plugin.last_no_ownpos_warning_at = 0;
|
|
128
|
+
plugin.lastOwnShipInsertAt = 0;
|
|
129
|
+
plugin.ownShipRouteId = uuidv4();
|
|
130
|
+
plugin.settings = settings || {};
|
|
121
131
|
|
|
122
132
|
plugin.cache = new Routecache(
|
|
123
133
|
path.join(packageDir, "data", "protocol", "migrations"),
|
|
@@ -132,6 +142,17 @@ module.exports = (app) => {
|
|
|
132
142
|
status_function: app.setPluginStatus.bind(app),
|
|
133
143
|
});
|
|
134
144
|
|
|
145
|
+
if (plugin.settings.mayara?.enabled) {
|
|
146
|
+
plugin.mayaraIngestor = new MayaraIngestor({
|
|
147
|
+
baseUrl: plugin.settings.mayara.base_url || "http://localhost:6502",
|
|
148
|
+
routecache: plugin.cache,
|
|
149
|
+
getOwnPosition: () => app.getSelfPath("navigation.position")?.value,
|
|
150
|
+
statusFn: (msg) => app.setPluginStatus(`Mayara: ${msg}`),
|
|
151
|
+
pollIntervalMs: plugin.settings.mayara.poll_interval_ms || 2000,
|
|
152
|
+
});
|
|
153
|
+
plugin.mayaraIngestor.start();
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
const now = new Date(1970, 1, 1);
|
|
136
157
|
plugin.route_updates = Array.from(Array(100)).map(() => now);
|
|
137
158
|
plugin.route_ids = Array.from(Array(100));
|
|
@@ -211,6 +232,8 @@ module.exports = (app) => {
|
|
|
211
232
|
plugin.id,
|
|
212
233
|
buildRattmDelta({
|
|
213
234
|
route_id: plugin.route_ids[b.target_id],
|
|
235
|
+
target_id: b.target_id,
|
|
236
|
+
target_status: b.target_status,
|
|
214
237
|
target_distance: b.target_distance,
|
|
215
238
|
target_bearing: b.target_bearing,
|
|
216
239
|
target_bearing_unit: b.target_bearing_unit,
|
|
@@ -245,6 +268,8 @@ module.exports = (app) => {
|
|
|
245
268
|
|
|
246
269
|
return buildRattmDelta({
|
|
247
270
|
route_id: plugin.route_ids[target_id],
|
|
271
|
+
target_id,
|
|
272
|
+
target_status,
|
|
248
273
|
target_distance,
|
|
249
274
|
target_bearing,
|
|
250
275
|
target_bearing_unit,
|
|
@@ -259,11 +284,20 @@ module.exports = (app) => {
|
|
|
259
284
|
},
|
|
260
285
|
});
|
|
261
286
|
|
|
262
|
-
app.streambundle
|
|
287
|
+
plugin.targetPositionSubscription = app.streambundle
|
|
263
288
|
.getBus("navigation.position")
|
|
264
289
|
.forEach(plugin.updatePosition);
|
|
290
|
+
|
|
291
|
+
if (plugin.settings.own_ship_tracking?.enabled !== false) {
|
|
292
|
+
plugin.ownShipPositionSubscription = app.streambundle
|
|
293
|
+
.getBus("navigation.position")
|
|
294
|
+
.forEach(plugin.updateOwnShipPosition);
|
|
295
|
+
}
|
|
265
296
|
},
|
|
266
297
|
stop: async () => {
|
|
298
|
+
plugin.targetPositionSubscription?.unsubscribe?.();
|
|
299
|
+
plugin.ownShipPositionSubscription?.unsubscribe?.();
|
|
300
|
+
await plugin.mayaraIngestor?.destroy?.();
|
|
267
301
|
await plugin.connector?.destroy?.();
|
|
268
302
|
await plugin.cache?.destroy?.();
|
|
269
303
|
console.log("Stopped KAHU radar Hub");
|
|
@@ -276,6 +310,43 @@ module.exports = (app) => {
|
|
|
276
310
|
api_key: { type: "string" },
|
|
277
311
|
min_reconnect_time: { type: "number", default: 100.0 },
|
|
278
312
|
max_reconnect_time: { type: "number", default: 600.0 },
|
|
313
|
+
own_ship_tracking: {
|
|
314
|
+
type: "object",
|
|
315
|
+
title: "Own-Ship Position Tracking",
|
|
316
|
+
properties: {
|
|
317
|
+
enabled: {
|
|
318
|
+
type: "boolean",
|
|
319
|
+
default: true,
|
|
320
|
+
title: "Track own vessel route from GPS",
|
|
321
|
+
},
|
|
322
|
+
sample_interval_s: {
|
|
323
|
+
type: "number",
|
|
324
|
+
default: 10,
|
|
325
|
+
title: "Own-ship sample interval in seconds",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
mayara: {
|
|
330
|
+
type: "object",
|
|
331
|
+
title: "Mayara Radar Server",
|
|
332
|
+
properties: {
|
|
333
|
+
enabled: {
|
|
334
|
+
type: "boolean",
|
|
335
|
+
default: false,
|
|
336
|
+
title: "Enable Mayara target ingestion",
|
|
337
|
+
},
|
|
338
|
+
base_url: {
|
|
339
|
+
type: "string",
|
|
340
|
+
default: "http://localhost:6502",
|
|
341
|
+
title: "Mayara server base URL",
|
|
342
|
+
},
|
|
343
|
+
poll_interval_ms: {
|
|
344
|
+
type: "number",
|
|
345
|
+
default: 2000,
|
|
346
|
+
title: "Mayara HTTP fallback poll interval (ms)",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
279
350
|
},
|
|
280
351
|
};
|
|
281
352
|
},
|
|
@@ -288,12 +359,48 @@ module.exports = (app) => {
|
|
|
288
359
|
|
|
289
360
|
plugin.cache.insert({
|
|
290
361
|
target_id: target_id,
|
|
362
|
+
data_kind: "arpaTarget",
|
|
363
|
+
source_type: "RATTM",
|
|
291
364
|
position: pos.value,
|
|
292
365
|
speedOverGround: rest?.navigation?.speedOverGround?.value,
|
|
293
366
|
courseOverGroundTrue: rest?.navigation?.courseOverGroundTrue?.value,
|
|
294
367
|
name: rest?.name?.value,
|
|
295
368
|
});
|
|
296
369
|
},
|
|
370
|
+
updateOwnShipPosition: (pos) => {
|
|
371
|
+
if (!pos?.value?.latitude || !pos?.value?.longitude) return;
|
|
372
|
+
if (pos?.source?.sentence === "TTM") return;
|
|
373
|
+
|
|
374
|
+
const selfContext =
|
|
375
|
+
typeof app.selfId === "string" ? `vessels.${app.selfId}` : null;
|
|
376
|
+
if (
|
|
377
|
+
pos.context &&
|
|
378
|
+
pos.context !== "vessels.self" &&
|
|
379
|
+
(!selfContext || pos.context !== selfContext)
|
|
380
|
+
) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const sampleIntervalMs =
|
|
385
|
+
(plugin.settings?.own_ship_tracking?.sample_interval_s || 10) * 1000;
|
|
386
|
+
const nowMs = Date.now();
|
|
387
|
+
if (nowMs - plugin.lastOwnShipInsertAt < sampleIntervalMs) return;
|
|
388
|
+
plugin.lastOwnShipInsertAt = nowMs;
|
|
389
|
+
|
|
390
|
+
plugin.cache.insert({
|
|
391
|
+
target_id: plugin.ownShipRouteId,
|
|
392
|
+
data_kind: "vesselRoute",
|
|
393
|
+
source_type: "gps",
|
|
394
|
+
position: {
|
|
395
|
+
latitude: pos.value.latitude,
|
|
396
|
+
longitude: pos.value.longitude,
|
|
397
|
+
},
|
|
398
|
+
speedOverGround: app.getSelfPath("navigation.speedOverGround")?.value,
|
|
399
|
+
courseOverGroundTrue: app.getSelfPath("navigation.courseOverGroundTrue")
|
|
400
|
+
?.value,
|
|
401
|
+
name: app.getSelfPath("name")?.value || "Own Vessel",
|
|
402
|
+
});
|
|
403
|
+
},
|
|
297
404
|
};
|
|
298
405
|
|
|
299
406
|
return plugin;
|
package/plugin/mayara.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const { v4: uuidv4 } = require("uuid");
|
|
2
|
+
const WebSocket = require("ws");
|
|
3
|
+
|
|
4
|
+
const rad2deg = (rad) => (Number.isFinite(rad) ? (rad * 180) / Math.PI : null);
|
|
5
|
+
|
|
6
|
+
class MayaraIngestor {
|
|
7
|
+
constructor({ baseUrl, routecache, getOwnPosition, statusFn, pollIntervalMs }) {
|
|
8
|
+
this.baseUrl = (baseUrl || "http://localhost:6502").replace(/\/+$/, "");
|
|
9
|
+
this.routecache = routecache;
|
|
10
|
+
this.getOwnPosition = getOwnPosition || (() => null);
|
|
11
|
+
this.statusFn = statusFn || (() => {});
|
|
12
|
+
this.pollIntervalMs = pollIntervalMs || 2000;
|
|
13
|
+
|
|
14
|
+
this.ws = null;
|
|
15
|
+
this.pollTimer = null;
|
|
16
|
+
this.reconnectTimer = null;
|
|
17
|
+
this.reconnectMs = 1000;
|
|
18
|
+
this.wsFailCount = 0;
|
|
19
|
+
this.destroyed = false;
|
|
20
|
+
|
|
21
|
+
this.radarIds = [];
|
|
22
|
+
this.targetUuids = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async start() {
|
|
26
|
+
await this._discoverRadars();
|
|
27
|
+
this._connectWebSocket();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async destroy() {
|
|
31
|
+
this.destroyed = true;
|
|
32
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
33
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
34
|
+
this.reconnectTimer = null;
|
|
35
|
+
this.pollTimer = null;
|
|
36
|
+
|
|
37
|
+
if (this.ws) {
|
|
38
|
+
try {
|
|
39
|
+
this.ws.close();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
this.statusFn(`Mayara WS close error: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
this.ws = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _discoverRadars() {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(
|
|
50
|
+
`${this.baseUrl}/signalk/v2/api/vessels/self/radars`,
|
|
51
|
+
);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
this.statusFn(`Mayara radar discovery failed: HTTP ${res.status}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const payload = await res.json();
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(payload)) {
|
|
59
|
+
this.radarIds = payload
|
|
60
|
+
.map((item) => item?.id || item?.radarId)
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
} else if (payload && typeof payload === "object") {
|
|
63
|
+
this.radarIds = Object.keys(payload);
|
|
64
|
+
} else {
|
|
65
|
+
this.radarIds = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.statusFn(`Mayara discovered ${this.radarIds.length} radar(s)`);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
this.statusFn(`Mayara radar discovery error: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_buildWsUrl() {
|
|
75
|
+
const url = new URL(this.baseUrl);
|
|
76
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
77
|
+
url.pathname = "/signalk/v1/stream";
|
|
78
|
+
url.search = "subscribe=none";
|
|
79
|
+
return url.toString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_connectWebSocket() {
|
|
83
|
+
if (this.destroyed) return;
|
|
84
|
+
const wsUrl = this._buildWsUrl();
|
|
85
|
+
|
|
86
|
+
this.ws = new WebSocket(wsUrl);
|
|
87
|
+
|
|
88
|
+
this.ws.on("open", () => {
|
|
89
|
+
this.wsFailCount = 0;
|
|
90
|
+
this.reconnectMs = 1000;
|
|
91
|
+
if (this.pollTimer) {
|
|
92
|
+
clearInterval(this.pollTimer);
|
|
93
|
+
this.pollTimer = null;
|
|
94
|
+
}
|
|
95
|
+
this.statusFn("Mayara WS connected");
|
|
96
|
+
this.ws.send(
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
context: "vessels.self",
|
|
99
|
+
subscribe: [{ path: "radars.*.targets.*", policy: "instant" }],
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.ws.on("message", (raw) => this._handleDelta(raw));
|
|
105
|
+
this.ws.on("error", (err) => this.statusFn(`Mayara WS error: ${err.message}`));
|
|
106
|
+
this.ws.on("close", () => this._scheduleReconnect());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_scheduleReconnect() {
|
|
110
|
+
if (this.destroyed) return;
|
|
111
|
+
this.wsFailCount += 1;
|
|
112
|
+
|
|
113
|
+
if (this.wsFailCount >= 3) this._startHttpFallback();
|
|
114
|
+
|
|
115
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
116
|
+
this.reconnectTimer = setTimeout(() => {
|
|
117
|
+
this._connectWebSocket();
|
|
118
|
+
}, this.reconnectMs);
|
|
119
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, 15000);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_getTargetUuid(radarId, targetId) {
|
|
123
|
+
const key = `${radarId}:${targetId}`;
|
|
124
|
+
if (!this.targetUuids.has(key)) this.targetUuids.set(key, uuidv4());
|
|
125
|
+
return this.targetUuids.get(key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_insertTarget(radarId, targetId, target) {
|
|
129
|
+
if (!target || target.status !== "tracking") return;
|
|
130
|
+
const lat = target.position?.latitude;
|
|
131
|
+
const lon = target.position?.longitude;
|
|
132
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
|
133
|
+
|
|
134
|
+
const ownPos = this.getOwnPosition?.();
|
|
135
|
+
if (!ownPos?.latitude || !ownPos?.longitude) return;
|
|
136
|
+
|
|
137
|
+
this.routecache.insert({
|
|
138
|
+
target_id: this._getTargetUuid(radarId, targetId),
|
|
139
|
+
data_kind: "arpaTarget",
|
|
140
|
+
source_type: "mayara",
|
|
141
|
+
target_status: target.status,
|
|
142
|
+
position: {
|
|
143
|
+
latitude: lat,
|
|
144
|
+
longitude: lon,
|
|
145
|
+
relative: {
|
|
146
|
+
position: ownPos,
|
|
147
|
+
distance: target.position?.distance,
|
|
148
|
+
bearing: rad2deg(target.position?.bearing),
|
|
149
|
+
bearing_unit: "T",
|
|
150
|
+
distance_unit: "M",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
speedOverGround: target.motion?.speed ?? null,
|
|
154
|
+
courseOverGroundTrue: rad2deg(target.motion?.course),
|
|
155
|
+
name: `ARPA-${targetId}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_handleDelta(raw) {
|
|
160
|
+
let msg;
|
|
161
|
+
try {
|
|
162
|
+
msg = JSON.parse(raw.toString());
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const update of msg?.updates || []) {
|
|
168
|
+
for (const value of update?.values || []) {
|
|
169
|
+
const path = value?.path || "";
|
|
170
|
+
const match = path.match(/^radars\.([^.]+)\.targets\.([^.]+)$/);
|
|
171
|
+
if (!match) continue;
|
|
172
|
+
if (!value.value) continue;
|
|
173
|
+
const radarId = match[1];
|
|
174
|
+
const targetId = match[2];
|
|
175
|
+
this._insertTarget(radarId, targetId, value.value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_startHttpFallback() {
|
|
181
|
+
if (this.pollTimer || this.destroyed) return;
|
|
182
|
+
this.statusFn("Mayara WS unstable, enabling HTTP fallback polling");
|
|
183
|
+
this.pollTimer = setInterval(async () => {
|
|
184
|
+
if (this.destroyed) return;
|
|
185
|
+
if (!this.radarIds.length) await this._discoverRadars();
|
|
186
|
+
for (const radarId of this.radarIds) {
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(
|
|
189
|
+
`${this.baseUrl}/signalk/v2/api/vessels/self/radars/${radarId}/targets`,
|
|
190
|
+
);
|
|
191
|
+
if (!res.ok) continue;
|
|
192
|
+
const targets = await res.json();
|
|
193
|
+
if (!Array.isArray(targets)) continue;
|
|
194
|
+
for (const target of targets) {
|
|
195
|
+
this._insertTarget(radarId, target?.id, target);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
this.statusFn(`Mayara HTTP poll error: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}, this.pollIntervalMs);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { MayaraIngestor };
|
package/plugin/routecache.js
CHANGED
|
@@ -193,9 +193,11 @@ class Routecache {
|
|
|
193
193
|
latitude,
|
|
194
194
|
longitude,
|
|
195
195
|
target_latitude,
|
|
196
|
-
target_longitude
|
|
196
|
+
target_longitude,
|
|
197
|
+
data_kind,
|
|
198
|
+
source_type
|
|
197
199
|
) values (
|
|
198
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
199
201
|
`,
|
|
200
202
|
[
|
|
201
203
|
target[0].target_id,
|
|
@@ -207,11 +209,13 @@ class Routecache {
|
|
|
207
209
|
'T',
|
|
208
210
|
props.position?.relative?.distance_unit,
|
|
209
211
|
props.name,
|
|
210
|
-
'T',
|
|
212
|
+
props.target_status || 'T',
|
|
211
213
|
props.position?.relative?.position?.latitude,
|
|
212
214
|
props.position?.relative?.position?.longitude,
|
|
213
215
|
props.position?.latitude,
|
|
214
216
|
props.position?.longitude,
|
|
217
|
+
props.data_kind || 'arpaTarget',
|
|
218
|
+
props.source_type || 'RATTM',
|
|
215
219
|
]
|
|
216
220
|
);
|
|
217
221
|
}
|
|
@@ -251,7 +255,9 @@ class Routecache {
|
|
|
251
255
|
(strftime('%s', timestamp) + strftime('%f', timestamp) - strftime('%S', timestamp)) * 1000
|
|
252
256
|
as timestamp_epoch,
|
|
253
257
|
target_position.target_latitude,
|
|
254
|
-
target_position.target_longitude
|
|
258
|
+
target_position.target_longitude,
|
|
259
|
+
target_position.data_kind,
|
|
260
|
+
target_position.source_type
|
|
255
261
|
from
|
|
256
262
|
target_position,
|
|
257
263
|
target
|
|
@@ -289,6 +295,8 @@ class Routecache {
|
|
|
289
295
|
route: [],
|
|
290
296
|
nmea: null,
|
|
291
297
|
start: Number(query[0].timestamp_epoch),
|
|
298
|
+
data_kind: query[0].data_kind || "arpaTarget",
|
|
299
|
+
source_type: query[0].source_type || "RATTM",
|
|
292
300
|
};
|
|
293
301
|
|
|
294
302
|
for (const row of query) {
|
|
Binary file
|