@marineyachtradar/signalk-plugin 0.1.3 → 0.2.1

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,510 @@
1
+ import van from "./van-1.5.2.debug.js";
2
+ import { fetchRadars, fetchInterfaces, isStandaloneMode, detectMode } from "./api.js";
3
+
4
+ const { a, tr, td, div, p, strong, details, summary, code, br, span } = van.tags;
5
+
6
+ // Network requirements for different radar brands
7
+ const NETWORK_REQUIREMENTS = {
8
+ furuno: {
9
+ ipRange: "172.31.x.x/16",
10
+ description: "Furuno DRS radars require the host to have an IP address in the 172.31.x.x range.",
11
+ setup: [
12
+ "Configure your network interface with an IP like 172.31.3.100/16",
13
+ "Connect to the radar network (usually via ethernet)",
14
+ "Ensure no firewall blocks UDP ports 10010, 10024, 10021"
15
+ ],
16
+ example: "ip addr add 172.31.3.100/16 dev eth1"
17
+ },
18
+ navico: {
19
+ ipRange: "236.6.7.x (multicast)",
20
+ description: "Navico (Simrad/Lowrance/B&G) radars use multicast.",
21
+ setup: ["Ensure your network supports multicast routing"]
22
+ },
23
+ raymarine: {
24
+ ipRange: "232.1.1.x (multicast)",
25
+ description: "Raymarine radars use multicast.",
26
+ setup: ["Ensure your network supports multicast routing"]
27
+ },
28
+ garmin: {
29
+ ipRange: "239.254.2.x (multicast)",
30
+ description: "Garmin xHD radars use multicast.",
31
+ setup: ["Ensure your network supports multicast routing"]
32
+ }
33
+ };
34
+
35
+ // Detect operating system
36
+ function detectOS() {
37
+ const ua = navigator.userAgent.toLowerCase();
38
+ const platform = navigator.platform?.toLowerCase() || '';
39
+
40
+ // Check mobile/tablet FIRST (iPadOS reports as macOS in Safari)
41
+ if (ua.includes('iphone') || ua.includes('ipad')) return 'ios';
42
+ // Also detect iPad via touch + macOS combination (iPadOS 13+ desktop mode)
43
+ if (navigator.maxTouchPoints > 1 && (ua.includes('mac') || platform.includes('mac'))) return 'ios';
44
+ if (ua.includes('android')) return 'android';
45
+
46
+ // Desktop OS detection
47
+ if (ua.includes('win') || platform.includes('win')) return 'windows';
48
+ if (ua.includes('mac') || platform.includes('mac')) return 'macos';
49
+ if (ua.includes('linux') || platform.includes('linux')) return 'linux';
50
+ return 'unknown';
51
+ }
52
+
53
+ // Detect browser
54
+ function detectBrowser() {
55
+ const ua = navigator.userAgent.toLowerCase();
56
+
57
+ if (ua.includes('edg/')) return 'edge';
58
+ if (ua.includes('chrome')) return 'chrome';
59
+ if (ua.includes('firefox')) return 'firefox';
60
+ if (ua.includes('safari') && !ua.includes('chrome')) return 'safari';
61
+ return 'unknown';
62
+ }
63
+
64
+ // Check if using a secure context
65
+ // Note: localhost and 127.0.0.1 are treated as secure contexts by browsers
66
+ function isSecureContext() {
67
+ return window.isSecureContext;
68
+ }
69
+
70
+ // Check WebGPU support and show appropriate warnings
71
+ async function checkWebGPUSupport() {
72
+ const warningDiv = document.getElementById('webgpu_warning');
73
+ if (!warningDiv) return true;
74
+
75
+ const os = detectOS();
76
+ const browser = detectBrowser();
77
+ const isSecure = isSecureContext();
78
+ const hasWebGPUApi = !!navigator.gpu;
79
+
80
+ // Track why WebGPU failed
81
+ let failureReason = null;
82
+ let hasWorkingAdapter = false;
83
+
84
+ if (hasWebGPUApi) {
85
+ try {
86
+ const adapter = await navigator.gpu.requestAdapter();
87
+ if (adapter) {
88
+ // WebGPU fully working!
89
+ warningDiv.style.display = 'none';
90
+ return true;
91
+ } else {
92
+ failureReason = 'no-adapter'; // API exists but no GPU adapter found
93
+ }
94
+ } catch (e) {
95
+ console.warn('WebGPU adapter request failed:', e);
96
+ failureReason = 'adapter-error';
97
+ }
98
+ } else {
99
+ // API doesn't exist - could be secure context issue or browser doesn't support it
100
+ failureReason = isSecure ? 'no-api' : 'insecure-context';
101
+ }
102
+
103
+ // Build warning message based on situation
104
+ warningDiv.style.display = 'block';
105
+ warningDiv.innerHTML = '';
106
+
107
+ const title = div({ class: 'myr_warning_title' }, 'WebGPU Not Available');
108
+ van.add(warningDiv, title);
109
+
110
+ const content = div({ class: 'myr_warning_content' });
111
+ van.add(warningDiv, content);
112
+
113
+ // Secure context warning (if not secure)
114
+ if (!isSecure) {
115
+ const hostname = window.location.hostname;
116
+ const port = window.location.port || '80';
117
+ const isMobile = (os === 'ios' || os === 'android');
118
+
119
+ van.add(content, div({ class: 'myr_warning_item myr_warning_https' },
120
+ strong('Secure Context Required'),
121
+ p('WebGPU requires a secure context. You are currently using HTTP on "', hostname, '".'),
122
+ p('Options:'),
123
+ div({ class: 'myr_warning_options' },
124
+ // Only show localhost option for desktop (SignalK won't run on mobile)
125
+ !isMobile ? div({ class: 'myr_warning_option' },
126
+ strong('Option 1 (easiest): '), 'Access via localhost instead:',
127
+ div({ class: 'myr_code_block' },
128
+ p(code('http://localhost:' + port), ' or ', code('http://127.0.0.1:' + port)),
129
+ p({ class: 'myr_note' }, 'Browsers treat localhost as a secure context')
130
+ )
131
+ ) : null,
132
+ div({ class: 'myr_warning_option' },
133
+ strong(isMobile ? 'Option 1: ' : 'Option 2: '), 'Add this site to browser exceptions:',
134
+ getInsecureOriginInstructions(browser, os)
135
+ ),
136
+ div({ class: 'myr_warning_option' },
137
+ strong(isMobile ? 'Option 2: ' : 'Option 3: '), 'Use HTTPS (requires server configuration)'
138
+ )
139
+ )
140
+ ));
141
+ }
142
+
143
+ // Always show browser-specific WebGPU/hardware acceleration instructions
144
+ van.add(content, div({ class: 'myr_warning_item' },
145
+ strong('Enable WebGPU / Hardware Acceleration'),
146
+ getBrowserInstructions(browser, os)
147
+ ));
148
+
149
+ return false;
150
+ }
151
+
152
+ function getInsecureOriginInstructions(browser, os) {
153
+ const origin = window.location.origin;
154
+
155
+ // iOS Safari has no way to add insecure origin exceptions
156
+ if (os === 'ios') {
157
+ return div({ class: 'myr_code_block' },
158
+ p('Safari on iOS/iPadOS does not support insecure origin exceptions.'),
159
+ p('Alternatives:'),
160
+ p('• Configure HTTPS on your SignalK server'),
161
+ p('• Use a tunneling service (e.g., ngrok) to get an HTTPS URL'),
162
+ p('• Access from a desktop browser where you can set the flag')
163
+ );
164
+ }
165
+
166
+ // Android Chrome
167
+ if (os === 'android' && browser === 'chrome') {
168
+ return div({ class: 'myr_code_block' },
169
+ p('1. Open Chrome on your Android device'),
170
+ p('2. Go to: ', code('chrome://flags/#unsafely-treat-insecure-origin-as-secure')),
171
+ p('3. Add: ', code(origin)),
172
+ p('4. Set to "Enabled"'),
173
+ p('5. Tap "Relaunch"')
174
+ );
175
+ }
176
+
177
+ switch (browser) {
178
+ case 'chrome':
179
+ case 'edge':
180
+ const flagPrefix = browser === 'edge' ? 'edge' : 'chrome';
181
+ const flagUrl = `${flagPrefix}://flags/#unsafely-treat-insecure-origin-as-secure`;
182
+ return div({ class: 'myr_code_block' },
183
+ p('1. Copy and paste this into your address bar:'),
184
+ p(a({ href: flagUrl, class: 'myr_flag_link' }, code(flagUrl))),
185
+ p('2. In the text field, add: ', code(origin)),
186
+ p('3. Set dropdown to "Enabled"'),
187
+ p('4. Click "Relaunch" at the bottom')
188
+ );
189
+ case 'firefox':
190
+ return div({ class: 'myr_code_block' },
191
+ p('1. Open: ', a({ href: 'about:config', class: 'myr_flag_link' }, code('about:config'))),
192
+ p('2. Click "Accept the Risk and Continue"'),
193
+ p('3. Search for: ', code('dom.securecontext.allowlist')),
194
+ p('4. Click the + button to add: ', code(window.location.hostname)),
195
+ p('5. Restart Firefox')
196
+ );
197
+ default:
198
+ return div({ class: 'myr_code_block' },
199
+ p('Check your browser settings for allowing insecure origins.')
200
+ );
201
+ }
202
+ }
203
+
204
+ function getBrowserInstructions(browser, os) {
205
+ // iOS/iPadOS Safari
206
+ if (browser === 'safari' && os === 'ios') {
207
+ return div({ class: 'myr_code_block' },
208
+ p('Safari on iOS/iPadOS 17+:'),
209
+ p('1. Open ', strong('Settings'), ' app'),
210
+ p('2. Scroll down and tap ', strong('Safari')),
211
+ p('3. Scroll down and tap ', strong('Advanced')),
212
+ p('4. Tap ', strong('Feature Flags')),
213
+ p('5. Enable ', strong('WebGPU')),
214
+ p('6. Return to Safari and reload this page'),
215
+ p({ class: 'myr_note' }, 'Note: Requires iOS/iPadOS 17 or later.')
216
+ );
217
+ }
218
+
219
+ switch (browser) {
220
+ case 'chrome':
221
+ return div({ class: 'myr_code_block' },
222
+ p('Chrome should have WebGPU enabled by default (v113+).'),
223
+ p('If not working, try:'),
224
+ p('1. Open: ', code('chrome://flags/#enable-unsafe-webgpu')),
225
+ p('2. Set to "Enabled"'),
226
+ p('3. Relaunch Chrome'),
227
+ os === 'linux' ? p({ class: 'myr_note' },
228
+ 'Note: On Linux, you may need Vulkan drivers installed.') : null
229
+ );
230
+ case 'edge':
231
+ return div({ class: 'myr_code_block' },
232
+ p('Edge should have WebGPU enabled by default.'),
233
+ p('If not working, try:'),
234
+ p('1. Open: ', code('edge://flags/#enable-unsafe-webgpu')),
235
+ p('2. Set to "Enabled"'),
236
+ p('3. Relaunch Edge')
237
+ );
238
+ case 'firefox':
239
+ return div({ class: 'myr_code_block' },
240
+ p('Firefox WebGPU is experimental:'),
241
+ p('1. Open: ', code('about:config')),
242
+ p('2. Search for: ', code('dom.webgpu.enabled')),
243
+ p('3. Set to: ', code('true')),
244
+ p('4. Restart Firefox'),
245
+ p({ class: 'myr_note' }, 'Note: Firefox WebGPU support is still in development.')
246
+ );
247
+ case 'safari':
248
+ return div({ class: 'myr_code_block' },
249
+ p('Safari WebGPU (macOS 14+):'),
250
+ p('1. Open Safari menu > Settings'),
251
+ p('2. Go to Advanced tab'),
252
+ p('3. Check "Show features for web developers"'),
253
+ p('4. Go to Feature Flags tab'),
254
+ p('5. Enable "WebGPU"'),
255
+ p('6. Restart Safari')
256
+ );
257
+ default:
258
+ return div({ class: 'myr_code_block' },
259
+ p('WebGPU requires a modern browser:'),
260
+ p('- Chrome 113+ (recommended)'),
261
+ p('- Edge 113+'),
262
+ p('- Safari 17+ (macOS/iOS)'),
263
+ p('- Firefox Nightly (experimental)')
264
+ );
265
+ }
266
+ }
267
+
268
+ function getHardwareAccelerationInstructions(browser, os) {
269
+ // iOS/iPadOS - no hardware acceleration toggle
270
+ if (os === 'ios') {
271
+ return div({ class: 'myr_code_block' },
272
+ p('On iOS/iPadOS, hardware acceleration cannot be disabled.'),
273
+ p('If WebGPU is not working:'),
274
+ p('• Ensure you have iOS/iPadOS 17 or later'),
275
+ p('• Try closing and reopening Safari'),
276
+ p('• Restart your device')
277
+ );
278
+ }
279
+
280
+ switch (browser) {
281
+ case 'chrome':
282
+ return div({ class: 'myr_code_block' },
283
+ p('1. Open: ', code('chrome://settings/system')),
284
+ p('2. Enable "Use graphics acceleration when available"'),
285
+ p('3. Relaunch Chrome')
286
+ );
287
+ case 'edge':
288
+ return div({ class: 'myr_code_block' },
289
+ p('1. Open: ', code('edge://settings/system')),
290
+ p('2. Enable "Use graphics acceleration when available"'),
291
+ p('3. Relaunch Edge')
292
+ );
293
+ case 'firefox':
294
+ return div({ class: 'myr_code_block' },
295
+ p('1. Open: ', code('about:preferences')),
296
+ p('2. Scroll to "Performance"'),
297
+ p('3. Uncheck "Use recommended performance settings"'),
298
+ p('4. Check "Use hardware acceleration when available"'),
299
+ p('5. Restart Firefox')
300
+ );
301
+ case 'safari':
302
+ return div({ class: 'myr_code_block' },
303
+ p('Safari uses hardware acceleration by default on macOS.'),
304
+ p('If WebGPU is not working:'),
305
+ p('• Ensure you have macOS 14 (Sonoma) or later'),
306
+ p('• Check that WebGPU is enabled in Feature Flags'),
307
+ p('• Try restarting Safari')
308
+ );
309
+ default:
310
+ return div({ class: 'myr_code_block' },
311
+ p('Check your browser settings for "Hardware acceleration"'),
312
+ p('or "Use GPU" and ensure it is enabled.'),
313
+ p('Then restart the browser.')
314
+ );
315
+ }
316
+ }
317
+
318
+ const RadarEntry = (radar) => {
319
+ // Build display name: "Brand Model (Name)" or "Brand Name" if no model
320
+ const brand = radar.brand || '';
321
+ const model = radar.model || '';
322
+ const name = radar.name || '';
323
+
324
+ let displayName;
325
+ if (model && model !== 'Unknown') {
326
+ displayName = `${brand} ${model} (${name})`;
327
+ } else {
328
+ displayName = `${brand} ${name}`;
329
+ }
330
+
331
+ return tr({ class: 'myr_radar_row' },
332
+ td({ class: 'myr_radar_name' }, displayName),
333
+ td({ class: 'myr_radar_actions' },
334
+ a({ href: "viewer.html?id=" + radar.id, class: 'myr_radar_link myr_radar_link_primary' },
335
+ 'Open Radar Display'
336
+ ),
337
+ a({ href: "control.html?id=" + radar.id, class: 'myr_radar_link myr_radar_link_secondary' },
338
+ 'Controls Only'
339
+ )
340
+ )
341
+ );
342
+ };
343
+
344
+ // Track previous radar count to avoid unnecessary DOM rebuilds
345
+ let previousRadarCount = -1;
346
+
347
+ function radarsLoaded(d) {
348
+ let radarIds = Object.keys(d);
349
+ let c = radarIds.length;
350
+ let r = document.getElementById("radars");
351
+
352
+ // Only rebuild if radar count changed (avoids collapsing the help details)
353
+ if (c === previousRadarCount && c === 0) {
354
+ // No change, just reschedule poll
355
+ setTimeout(loadRadars, 2000);
356
+ return;
357
+ }
358
+ previousRadarCount = c;
359
+
360
+ // Clear previous content
361
+ r.innerHTML = "";
362
+
363
+ if (c > 0) {
364
+ van.add(r, div({ class: 'myr_section_title' },
365
+ span({ class: 'myr_radar_count' }, c),
366
+ ' Radar' + (c > 1 ? 's' : '') + ' Detected'
367
+ ));
368
+
369
+ let table = document.createElement("table");
370
+ table.className = 'myr_radar_table';
371
+ r.appendChild(table);
372
+
373
+ radarIds.sort().forEach(function (v, i) {
374
+ // Pass the full radar object (includes id, name, brand, model)
375
+ const radar = { ...d[v], id: v };
376
+ van.add(table, RadarEntry(radar));
377
+ });
378
+
379
+ // Radar found, poll less frequently
380
+ setTimeout(loadRadars, 15000);
381
+ } else {
382
+ van.add(r, div({ class: 'myr_detecting' },
383
+ span({ class: 'myr_pulse' }),
384
+ 'Searching for radars...'
385
+ ));
386
+
387
+ // Show network requirements help
388
+ van.add(r,
389
+ details({ class: 'myr_network_help' },
390
+ summary('Network Configuration Help'),
391
+ div({ class: 'myr_help_content' },
392
+ // Furuno section
393
+ div({ class: 'myr_brand_section' },
394
+ div({ class: 'myr_brand_header' }, 'Furuno DRS (DRS4D-NXT, DRS6A-NXT, etc.)'),
395
+ p(NETWORK_REQUIREMENTS.furuno.description),
396
+ div({ class: 'myr_setup_steps' },
397
+ NETWORK_REQUIREMENTS.furuno.setup.map((step, i) =>
398
+ div({ class: 'myr_setup_step' }, (i + 1) + '. ' + step)
399
+ )
400
+ ),
401
+ div({ class: 'myr_code_example' },
402
+ code(NETWORK_REQUIREMENTS.furuno.example)
403
+ )
404
+ ),
405
+
406
+ // Other brands
407
+ div({ class: 'myr_brand_section myr_brand_other' },
408
+ div({ class: 'myr_brand_header' }, 'Navico (Simrad, Lowrance, B&G)'),
409
+ p(NETWORK_REQUIREMENTS.navico.description)
410
+ ),
411
+
412
+ div({ class: 'myr_brand_section myr_brand_other' },
413
+ div({ class: 'myr_brand_header' }, 'Raymarine'),
414
+ p(NETWORK_REQUIREMENTS.raymarine.description)
415
+ ),
416
+
417
+ div({ class: 'myr_brand_section myr_brand_other' },
418
+ div({ class: 'myr_brand_header' }, 'Garmin xHD'),
419
+ p(NETWORK_REQUIREMENTS.garmin.description)
420
+ )
421
+ )
422
+ )
423
+ );
424
+
425
+ // No radar found, poll more frequently (every 2 seconds)
426
+ setTimeout(loadRadars, 2000);
427
+ }
428
+ }
429
+
430
+ function interfacesLoaded(d) {
431
+ if (!d || !d.interfaces) {
432
+ return;
433
+ }
434
+
435
+ let c = Object.keys(d.interfaces).length;
436
+ if (c > 0) {
437
+ let r = document.getElementById("interfaces");
438
+ r.innerHTML = "";
439
+
440
+ van.add(r, div({ class: 'myr_section_title' }, 'Network Interfaces'));
441
+
442
+ let table = document.createElement("table");
443
+ table.className = 'myr_interface_table';
444
+ r.appendChild(table);
445
+
446
+ let brands = ["Interface", ...d.brands];
447
+ let hdr = van.add(table, tr({ class: 'myr_interface_header' }));
448
+ brands.forEach((v) => van.add(hdr, td(v)));
449
+
450
+ let interfaces = d.interfaces;
451
+ if (interfaces) {
452
+ console.log("interfaces", interfaces);
453
+ Object.keys(interfaces).forEach(function (v, i) {
454
+ let row = van.add(table, tr());
455
+
456
+ van.add(row, td({ class: 'myr_interface_name' }, v));
457
+ if (interfaces[v].status) {
458
+ van.add(row, td({ class: 'myr_interface_error', colspan: d.brands.length }, interfaces[v].status));
459
+ } else {
460
+ d.brands.forEach((b) => {
461
+ let status = interfaces[v].listeners[b];
462
+ let className = (status == "Listening" || status == "Active")
463
+ ? 'myr_interface_ok'
464
+ : 'myr_interface_error';
465
+ van.add(row, td({ class: className }, status));
466
+ });
467
+ }
468
+ });
469
+ }
470
+ }
471
+ }
472
+
473
+ async function loadRadars() {
474
+ try {
475
+ const radars = await fetchRadars();
476
+ radarsLoaded(radars);
477
+ } catch (err) {
478
+ console.error("Failed to load radars:", err);
479
+ setTimeout(loadRadars, 15000);
480
+ }
481
+ }
482
+
483
+ async function loadInterfaces() {
484
+ try {
485
+ const interfaces = await fetchInterfaces();
486
+ if (interfaces) {
487
+ interfacesLoaded(interfaces);
488
+ } else {
489
+ // Hide interfaces section in SignalK mode
490
+ let r = document.getElementById("interfaces");
491
+ if (r) {
492
+ r.style.display = "none";
493
+ }
494
+ }
495
+ } catch (err) {
496
+ console.error("Failed to load interfaces:", err);
497
+ }
498
+ }
499
+
500
+ window.onload = async function () {
501
+ // Check WebGPU support first
502
+ await checkWebGPUSupport();
503
+
504
+ // Detect mode
505
+ await detectMode();
506
+
507
+ // Load data
508
+ loadRadars();
509
+ loadInterfaces();
510
+ };
@@ -0,0 +1,41 @@
1
+ syntax = "proto3";
2
+ option go_package = "../radar";
3
+
4
+ /*
5
+ * The data stream coming from a radar is a series of spokes.
6
+ * The number of spokes per revolution is different for each type of
7
+ * radar and can be found in the radar specification found at
8
+ * .../radars as 'spokes_per_revolution'. The maximum length of each
9
+ * spoke is also defined there, as well as the legend that provides
10
+ * a lookup table for each byte of data in the spoke.
11
+ *
12
+ * The angle and bearing fields below are in terms of spokes, so
13
+ * range from [0..spokes_per_revolution>.
14
+ *
15
+ * Angle is a mandatory field and tells you the rotation of the spoke
16
+ * relative to the front of the boat, going clockwise. 0 means directly
17
+ * ahead, spokes_per_revolution / 4 is to starboard, spokes_per_revolution / 2 is directly astern, etc.
18
+ *
19
+ * Bearing, if set, means that either the radar or the radar server has
20
+ * enriched the data with a true bearing, e.g. 0 is directly North,
21
+ * spokes_per_revolution / 4 is directly West, spokes_per_revolution / 2 is South, etc.
22
+ *
23
+ * Likewise, time and lat/lon indicate the best effort when the spoke
24
+ * was generated, and the lat/lon of the radar at the time of generation.
25
+ *
26
+ * Latitude and longitude are expressed in 10**-16 degrees, for compatibility
27
+ * with NMEA-2000 data.
28
+ */
29
+ message RadarMessage {
30
+ uint32 radar = 1;
31
+ message Spoke {
32
+ uint32 angle = 1; // [0..spokes_per_revolution>, angle from bow
33
+ optional uint32 bearing = 2; // [0..spokes_per_revolution>, offset from True North
34
+ uint32 range = 3; // [meters], range in meters of the last pixel in data
35
+ optional uint64 time = 4; // [millis since UNIX epoch] Time when spoke was generated or received
36
+ optional int64 lat = 6; // [1e-16 degree] Location of radar at time of generation
37
+ optional int64 lon = 7; // [1e-16 degree] Location of radar at time of generation
38
+ bytes data = 5;
39
+ }
40
+ repeated Spoke spokes = 2;
41
+ }