kahu-signalk 0.0.13 → 0.0.15

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.
@@ -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/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
- - Only supports `$RATTM` sentences (not `$RATTL` -- target list sentences)
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
- └── 0002-target-indices.sql
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.13",
3
+ "version": "0.0.15",
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/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;
@@ -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 };
@@ -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
  }
Binary file