@marineyachtradar/signalk-playback-plugin 0.1.1 → 0.2.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/README.md +8 -13
- package/package.json +3 -3
- package/public/api.js +402 -0
- package/public/assets/MaYaRa_RED.png +0 -0
- package/public/base.css +91 -0
- package/public/control.html +23 -0
- package/public/control.js +1155 -0
- package/public/controls.css +538 -0
- package/public/discovery.css +478 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +10 -0
- package/public/layout.css +87 -0
- package/public/mayara.js +510 -0
- package/public/playback.html +572 -0
- package/public/proto/RadarMessage.proto +41 -0
- package/public/protobuf/protobuf.js +9112 -0
- package/public/protobuf/protobuf.js.map +1 -0
- package/public/protobuf/protobuf.min.js +8 -0
- package/public/protobuf/protobuf.min.js.map +1 -0
- package/public/radar.svg +29 -0
- package/public/render_webgpu.js +886 -0
- package/public/responsive.css +29 -0
- package/public/van-1.5.2.debug.js +126 -0
- package/public/van-1.5.2.js +140 -0
- package/public/van-1.5.2.min.js +1 -0
- package/public/viewer.html +30 -0
- package/public/viewer.js +797 -0
- package/build.js +0 -248
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capabilities-Driven Radar Control Panel
|
|
3
|
+
*
|
|
4
|
+
* Dynamically builds the control UI based on radar capabilities from the v5 API.
|
|
5
|
+
* No hardcoded controls - everything is generated from the capability manifest.
|
|
6
|
+
*
|
|
7
|
+
* UI Design: Touch-friendly with sliders and buttons only (no dropdowns).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { loadRadar, registerRadarCallback, registerControlCallback, setCurrentRange, getPowerState, getOperatingHours, hasHoursCapability };
|
|
11
|
+
|
|
12
|
+
import van from "./van-1.5.2.js";
|
|
13
|
+
import { fetchRadarIds, fetchCapabilities, fetchState, setControl, detectMode, isStandaloneMode, saveInstallationSetting } from "./api.js";
|
|
14
|
+
|
|
15
|
+
const { div, label, input, button, span } = van.tags;
|
|
16
|
+
|
|
17
|
+
// State
|
|
18
|
+
let radarId = null;
|
|
19
|
+
let capabilities = null;
|
|
20
|
+
let radarState = null;
|
|
21
|
+
let statePollingInterval = null;
|
|
22
|
+
let callbacks = [];
|
|
23
|
+
let controlCallbacks = [];
|
|
24
|
+
|
|
25
|
+
// Current range (for viewer.js integration)
|
|
26
|
+
let currentRange = 1852;
|
|
27
|
+
let lastRangeUpdateTime = 0;
|
|
28
|
+
let rangeUpdateCount = {}; // Track how often each range value is seen
|
|
29
|
+
let userRequestedRangeIndex = -1; // Track user's position in range table
|
|
30
|
+
let rangeFromSpokeData = false; // True once we've received range from spoke data
|
|
31
|
+
|
|
32
|
+
// Track pending control changes to prevent polling from overwriting user input
|
|
33
|
+
// Maps controlId -> { value, timestamp }
|
|
34
|
+
let pendingControls = {};
|
|
35
|
+
|
|
36
|
+
function registerRadarCallback(callback) {
|
|
37
|
+
callbacks.push(callback);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function registerControlCallback(callback) {
|
|
41
|
+
controlCallbacks.push(callback);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Called from viewer.js when spoke data contains range
|
|
45
|
+
// Uses majority voting to prevent flickering from mixed range values during transitions
|
|
46
|
+
function setCurrentRange(meters) {
|
|
47
|
+
if (meters <= 0) return;
|
|
48
|
+
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
|
|
51
|
+
// Reset counts if more than 2 seconds since last update
|
|
52
|
+
if (now - lastRangeUpdateTime > 2000) {
|
|
53
|
+
rangeUpdateCount = {};
|
|
54
|
+
}
|
|
55
|
+
lastRangeUpdateTime = now;
|
|
56
|
+
|
|
57
|
+
// Count this range value
|
|
58
|
+
rangeUpdateCount[meters] = (rangeUpdateCount[meters] || 0) + 1;
|
|
59
|
+
|
|
60
|
+
// Find the most common range value (need at least 5 samples)
|
|
61
|
+
let maxCount = 0;
|
|
62
|
+
let dominantRange = currentRange;
|
|
63
|
+
for (const [range, count] of Object.entries(rangeUpdateCount)) {
|
|
64
|
+
if (count > maxCount) {
|
|
65
|
+
maxCount = count;
|
|
66
|
+
dominantRange = parseInt(range);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only update if we have a clear majority (5+ samples) and it's different
|
|
71
|
+
if (maxCount >= 5 && dominantRange !== currentRange) {
|
|
72
|
+
currentRange = dominantRange;
|
|
73
|
+
rangeFromSpokeData = true; // Mark that we have real range from radar
|
|
74
|
+
// Also update userRequestedRangeIndex to match spoke data
|
|
75
|
+
const ranges = capabilities?.characteristics?.supportedRanges || [];
|
|
76
|
+
const newIndex = ranges.findIndex(r => Math.abs(r - dominantRange) < 50);
|
|
77
|
+
if (newIndex >= 0) {
|
|
78
|
+
userRequestedRangeIndex = newIndex;
|
|
79
|
+
}
|
|
80
|
+
rangeUpdateCount = {}; // Reset after accepting new range
|
|
81
|
+
updateRangeDisplay();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// UI Building from Capabilities
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the entire control panel from capabilities
|
|
91
|
+
*/
|
|
92
|
+
function buildControlsFromCapabilities() {
|
|
93
|
+
const titleEl = document.getElementById("myr_title");
|
|
94
|
+
const controlsEl = document.getElementById("myr_controls");
|
|
95
|
+
|
|
96
|
+
if (!capabilities || !controlsEl) return;
|
|
97
|
+
|
|
98
|
+
// Set title
|
|
99
|
+
if (titleEl) {
|
|
100
|
+
titleEl.innerHTML = "";
|
|
101
|
+
van.add(titleEl, div(`${capabilities.make || ''} ${capabilities.model || ''} Controls`));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Clear controls
|
|
105
|
+
controlsEl.innerHTML = "";
|
|
106
|
+
|
|
107
|
+
// Build radar info header showing model, serial, firmware, etc.
|
|
108
|
+
const infoItems = [];
|
|
109
|
+
if (capabilities.model) {
|
|
110
|
+
infoItems.push({ label: "Model", value: capabilities.model });
|
|
111
|
+
}
|
|
112
|
+
if (capabilities.serialNumber) {
|
|
113
|
+
infoItems.push({ label: "Serial", value: capabilities.serialNumber });
|
|
114
|
+
}
|
|
115
|
+
if (capabilities.firmwareVersion) {
|
|
116
|
+
infoItems.push({ label: "Firmware", value: capabilities.firmwareVersion });
|
|
117
|
+
}
|
|
118
|
+
if (capabilities.characteristics?.maxRange) {
|
|
119
|
+
const maxNm = (capabilities.characteristics.maxRange / 1852).toFixed(0);
|
|
120
|
+
infoItems.push({ label: "Max Range", value: `${maxNm} nm` });
|
|
121
|
+
}
|
|
122
|
+
if (capabilities.characteristics?.hasDoppler) {
|
|
123
|
+
infoItems.push({ label: "Doppler", value: "Yes" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (infoItems.length > 0) {
|
|
127
|
+
const infoHeader = div({ class: "myr_radar_info_header" },
|
|
128
|
+
...infoItems.map(item =>
|
|
129
|
+
div({ class: "myr_radar_info_item" },
|
|
130
|
+
span({ class: "myr_info_label" }, item.label + ":"),
|
|
131
|
+
span({ class: "myr_info_value" }, item.value)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
van.add(controlsEl, infoHeader);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Group controls by category
|
|
139
|
+
const baseControls = [];
|
|
140
|
+
const extendedControls = [];
|
|
141
|
+
const configControls = [];
|
|
142
|
+
const infoControls = [];
|
|
143
|
+
|
|
144
|
+
for (const control of capabilities.controls || []) {
|
|
145
|
+
if (control.readOnly) {
|
|
146
|
+
infoControls.push(control);
|
|
147
|
+
} else if (control.category === 'installation') {
|
|
148
|
+
configControls.push(control);
|
|
149
|
+
} else if (control.category === 'extended') {
|
|
150
|
+
extendedControls.push(control);
|
|
151
|
+
} else {
|
|
152
|
+
baseControls.push(control);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build base controls (power, range, gain, sea, rain)
|
|
157
|
+
if (baseControls.length > 0) {
|
|
158
|
+
const baseSection = div({ class: "myr_control_section" });
|
|
159
|
+
|
|
160
|
+
// Power control first (special handling)
|
|
161
|
+
const powerControl = baseControls.find(c => c.id === 'power');
|
|
162
|
+
if (powerControl) {
|
|
163
|
+
van.add(baseSection, buildPowerControl(powerControl));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Range control (special handling with +/- buttons)
|
|
167
|
+
const rangeControl = baseControls.find(c => c.id === 'range');
|
|
168
|
+
if (rangeControl) {
|
|
169
|
+
van.add(baseSection, buildRangeControl(rangeControl));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Other base controls
|
|
173
|
+
for (const control of baseControls) {
|
|
174
|
+
if (control.id !== 'power' && control.id !== 'range') {
|
|
175
|
+
van.add(baseSection, buildControl(control));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
van.add(controlsEl, baseSection);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Build extended controls in a collapsible section
|
|
183
|
+
if (extendedControls.length > 0) {
|
|
184
|
+
const extSection = div({ class: "myr_control_section myr_extended_section" },
|
|
185
|
+
div({ class: "myr_section_header" }, "Advanced Controls")
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
for (const control of extendedControls) {
|
|
189
|
+
van.add(extSection, buildControl(control));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
van.add(controlsEl, extSection);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Build installation controls (config settings - rarely changed)
|
|
196
|
+
if (configControls.length > 0) {
|
|
197
|
+
const configSection = div({ class: "myr_control_section myr_installation_section" },
|
|
198
|
+
div({ class: "myr_section_header" }, "Installation")
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
for (const control of configControls) {
|
|
202
|
+
van.add(configSection, buildControl(control));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
van.add(controlsEl, configSection);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Build info controls (read-only)
|
|
209
|
+
if (infoControls.length > 0) {
|
|
210
|
+
const infoSection = div({ class: "myr_control_section myr_info_section" },
|
|
211
|
+
div({ class: "myr_section_header" }, "Radar Information")
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
for (const control of infoControls) {
|
|
215
|
+
van.add(infoSection, buildInfoControl(control));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
van.add(controlsEl, infoSection);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Apply initial state
|
|
222
|
+
if (radarState) {
|
|
223
|
+
applyStateToUI(radarState);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build a control widget based on its type and schema
|
|
229
|
+
*/
|
|
230
|
+
function buildControl(control) {
|
|
231
|
+
// Special case for dopplerMode - needs custom UI (enabled toggle + mode selector)
|
|
232
|
+
if (control.id === 'dopplerMode') {
|
|
233
|
+
return buildDopplerModeControl(control);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Special case for noTransmitZones - needs custom UI (2 zone editors)
|
|
237
|
+
if (control.id === 'noTransmitZones') {
|
|
238
|
+
return buildNoTransmitZonesControl(control);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
switch (control.type) {
|
|
242
|
+
case 'boolean':
|
|
243
|
+
return buildBooleanControl(control);
|
|
244
|
+
case 'number':
|
|
245
|
+
return buildNumberControl(control);
|
|
246
|
+
case 'enum':
|
|
247
|
+
return buildEnumControl(control);
|
|
248
|
+
case 'compound':
|
|
249
|
+
return buildCompoundControl(control);
|
|
250
|
+
default:
|
|
251
|
+
console.warn(`Unknown control type: ${control.type} for ${control.id}`);
|
|
252
|
+
return div();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Power control - special Transmit/Standby buttons
|
|
258
|
+
*/
|
|
259
|
+
function buildPowerControl(control) {
|
|
260
|
+
const currentValue = getControlValue('power') || 'standby';
|
|
261
|
+
|
|
262
|
+
return div({ class: "myr_power_buttons" },
|
|
263
|
+
button({
|
|
264
|
+
type: "button",
|
|
265
|
+
class: `myr_power_button myr_power_button_transmit ${currentValue === 'transmit' ? 'myr_power_active' : ''}`,
|
|
266
|
+
onclick: () => sendControlValue('power', 'transmit'),
|
|
267
|
+
}, "Transmit"),
|
|
268
|
+
button({
|
|
269
|
+
type: "button",
|
|
270
|
+
class: `myr_power_button myr_power_button_standby ${currentValue === 'standby' ? 'myr_power_active' : ''}`,
|
|
271
|
+
onclick: () => sendControlValue('power', 'standby'),
|
|
272
|
+
}, "Standby")
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Range control - +/- buttons with display
|
|
278
|
+
*/
|
|
279
|
+
function buildRangeControl(control) {
|
|
280
|
+
// Get supported ranges from characteristics
|
|
281
|
+
const ranges = capabilities.characteristics?.supportedRanges || [];
|
|
282
|
+
|
|
283
|
+
return div({ class: "myr_range_buttons" },
|
|
284
|
+
button({
|
|
285
|
+
type: "button",
|
|
286
|
+
class: "myr_range_button",
|
|
287
|
+
onclick: () => changeRange(-1),
|
|
288
|
+
}, "Range -"),
|
|
289
|
+
button({
|
|
290
|
+
type: "button",
|
|
291
|
+
class: "myr_range_button",
|
|
292
|
+
onclick: () => changeRange(1),
|
|
293
|
+
}, "Range +")
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Boolean control - toggle button
|
|
299
|
+
*/
|
|
300
|
+
function buildBooleanControl(control) {
|
|
301
|
+
const currentValue = getControlValue(control.id) || control.default || false;
|
|
302
|
+
|
|
303
|
+
return div({ class: "myr_control myr_boolean_control" },
|
|
304
|
+
span({ class: "myr_control_label" }, control.name),
|
|
305
|
+
button({
|
|
306
|
+
type: "button",
|
|
307
|
+
id: `myr_${control.id}`,
|
|
308
|
+
class: `myr_toggle_button ${currentValue ? 'myr_toggle_active' : ''}`,
|
|
309
|
+
onclick: (e) => {
|
|
310
|
+
const isActive = e.target.classList.contains('myr_toggle_active');
|
|
311
|
+
sendControlValue(control.id, !isActive);
|
|
312
|
+
},
|
|
313
|
+
}, currentValue ? "On" : "Off")
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Number control - slider
|
|
319
|
+
*/
|
|
320
|
+
function buildNumberControl(control) {
|
|
321
|
+
const range = control.range || { min: 0, max: 100 };
|
|
322
|
+
let currentValue = getControlValue(control.id);
|
|
323
|
+
|
|
324
|
+
// Handle compound values (objects with mode/value)
|
|
325
|
+
if (typeof currentValue === 'object' && currentValue !== null) {
|
|
326
|
+
currentValue = currentValue.value;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const value = currentValue !== undefined ? currentValue : (control.default || range.min);
|
|
330
|
+
|
|
331
|
+
return div({ class: "myr_control myr_number_control" },
|
|
332
|
+
div({ class: "myr_control_header" },
|
|
333
|
+
span({ class: "myr_control_label" }, control.name),
|
|
334
|
+
span({ id: `myr_${control.id}_value`, class: "myr_control_value" },
|
|
335
|
+
formatNumberValue(value, control))
|
|
336
|
+
),
|
|
337
|
+
input({
|
|
338
|
+
type: "range",
|
|
339
|
+
id: `myr_${control.id}`,
|
|
340
|
+
class: "myr_slider",
|
|
341
|
+
min: range.min,
|
|
342
|
+
max: range.max,
|
|
343
|
+
step: range.step || 1,
|
|
344
|
+
value: value,
|
|
345
|
+
oninput: (e) => {
|
|
346
|
+
// Update display while dragging
|
|
347
|
+
const valEl = document.getElementById(`myr_${control.id}_value`);
|
|
348
|
+
if (valEl) {
|
|
349
|
+
valEl.textContent = formatNumberValue(parseInt(e.target.value), control);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
onchange: (e) => {
|
|
353
|
+
sendControlValue(control.id, parseInt(e.target.value));
|
|
354
|
+
},
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Enum control - row of buttons (no dropdown per user request)
|
|
361
|
+
*/
|
|
362
|
+
function buildEnumControl(control) {
|
|
363
|
+
const values = control.values || [];
|
|
364
|
+
const currentValue = getControlValue(control.id) ?? control.default;
|
|
365
|
+
|
|
366
|
+
return div({ class: "myr_control myr_enum_control" },
|
|
367
|
+
span({ class: "myr_control_label" }, control.name),
|
|
368
|
+
div({ class: "myr_button_group", id: `myr_${control.id}_group` },
|
|
369
|
+
...values.map(v => {
|
|
370
|
+
// Compare as strings to handle number/string type differences
|
|
371
|
+
const isActive = String(v.value) === String(currentValue);
|
|
372
|
+
return button({
|
|
373
|
+
type: "button",
|
|
374
|
+
class: `myr_enum_button ${isActive ? 'myr_enum_active' : ''}`,
|
|
375
|
+
"data-value": v.value,
|
|
376
|
+
onclick: () => sendControlValue(control.id, v.value),
|
|
377
|
+
}, v.label || v.value);
|
|
378
|
+
})
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Compound control - mode selector + value slider (e.g., gain with auto/manual)
|
|
385
|
+
*/
|
|
386
|
+
function buildCompoundControl(control) {
|
|
387
|
+
const modes = control.modes || ['auto', 'manual'];
|
|
388
|
+
const currentState = getControlValue(control.id) || {};
|
|
389
|
+
const currentMode = currentState.mode || control.defaultMode || modes[0];
|
|
390
|
+
const currentValue = currentState.value !== undefined ? currentState.value : 50;
|
|
391
|
+
|
|
392
|
+
// Get value range from properties
|
|
393
|
+
const valueProps = control.properties?.value || {};
|
|
394
|
+
const range = valueProps.range || { min: 0, max: 100 };
|
|
395
|
+
|
|
396
|
+
const isAuto = currentMode === 'auto';
|
|
397
|
+
|
|
398
|
+
return div({ class: "myr_control myr_compound_control", id: `myr_${control.id}_compound` },
|
|
399
|
+
div({ class: "myr_compound_header" },
|
|
400
|
+
span({ class: "myr_control_label" }, control.name),
|
|
401
|
+
span({ id: `myr_${control.id}_value`, class: "myr_control_value" },
|
|
402
|
+
isAuto ? "Auto" : currentValue)
|
|
403
|
+
),
|
|
404
|
+
div({ class: "myr_compound_body" },
|
|
405
|
+
// Mode buttons
|
|
406
|
+
div({ class: "myr_mode_buttons" },
|
|
407
|
+
...modes.map(mode =>
|
|
408
|
+
button({
|
|
409
|
+
type: "button",
|
|
410
|
+
class: `myr_mode_button ${mode === currentMode ? 'myr_mode_active' : ''}`,
|
|
411
|
+
"data-mode": mode,
|
|
412
|
+
onclick: () => {
|
|
413
|
+
const slider = document.getElementById(`myr_${control.id}_slider`);
|
|
414
|
+
const value = slider ? parseInt(slider.value) : currentValue;
|
|
415
|
+
sendControlValue(control.id, { mode, value });
|
|
416
|
+
},
|
|
417
|
+
}, mode.charAt(0).toUpperCase() + mode.slice(1))
|
|
418
|
+
)
|
|
419
|
+
),
|
|
420
|
+
// Value slider (disabled when auto)
|
|
421
|
+
input({
|
|
422
|
+
type: "range",
|
|
423
|
+
id: `myr_${control.id}_slider`,
|
|
424
|
+
class: "myr_slider myr_compound_slider",
|
|
425
|
+
min: range.min,
|
|
426
|
+
max: range.max,
|
|
427
|
+
step: range.step || 1,
|
|
428
|
+
value: currentValue,
|
|
429
|
+
disabled: isAuto,
|
|
430
|
+
oninput: (e) => {
|
|
431
|
+
const valEl = document.getElementById(`myr_${control.id}_value`);
|
|
432
|
+
// Check current mode dynamically, not the captured isAuto
|
|
433
|
+
const modeEl = document.querySelector(`#myr_${control.id}_compound .myr_mode_active`);
|
|
434
|
+
const currentMode = modeEl?.dataset.mode || 'auto';
|
|
435
|
+
if (valEl && currentMode !== 'auto') {
|
|
436
|
+
valEl.textContent = e.target.value;
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
onchange: (e) => {
|
|
440
|
+
// Check current mode dynamically
|
|
441
|
+
const modeEl = document.querySelector(`#myr_${control.id}_compound .myr_mode_active`);
|
|
442
|
+
const mode = modeEl?.dataset.mode || 'manual';
|
|
443
|
+
if (mode !== 'auto') {
|
|
444
|
+
sendControlValue(control.id, { mode, value: parseInt(e.target.value) });
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
)
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Doppler Mode control - 3 buttons: Off | Target | Rain
|
|
454
|
+
* Furuno Target Analyzer: { enabled: bool, mode: "target" | "rain" }
|
|
455
|
+
*/
|
|
456
|
+
function buildDopplerModeControl(control) {
|
|
457
|
+
const currentState = getControlValue(control.id) || { enabled: false, mode: 'target' };
|
|
458
|
+
const enabled = currentState.enabled || false;
|
|
459
|
+
const mode = currentState.mode || 'target';
|
|
460
|
+
|
|
461
|
+
// Determine which button is active: off, target, or rain
|
|
462
|
+
const activeBtn = !enabled ? 'off' : mode;
|
|
463
|
+
|
|
464
|
+
return div({ class: "myr_control", id: `myr_${control.id}_compound` },
|
|
465
|
+
span({ class: "myr_control_label" }, control.name),
|
|
466
|
+
div({ class: "myr_mode_buttons myr_mode_buttons_3" },
|
|
467
|
+
button({
|
|
468
|
+
type: "button",
|
|
469
|
+
class: `myr_mode_button ${activeBtn === 'off' ? 'myr_mode_active' : ''}`,
|
|
470
|
+
"data-value": "off",
|
|
471
|
+
onclick: () => sendControlValue(control.id, { enabled: false, mode: 'target' }),
|
|
472
|
+
}, "Off"),
|
|
473
|
+
button({
|
|
474
|
+
type: "button",
|
|
475
|
+
class: `myr_mode_button ${activeBtn === 'target' ? 'myr_mode_active' : ''}`,
|
|
476
|
+
"data-value": "target",
|
|
477
|
+
onclick: () => sendControlValue(control.id, { enabled: true, mode: 'target' }),
|
|
478
|
+
}, "Target"),
|
|
479
|
+
button({
|
|
480
|
+
type: "button",
|
|
481
|
+
class: `myr_mode_button ${activeBtn === 'rain' ? 'myr_mode_active' : ''}`,
|
|
482
|
+
"data-value": "rain",
|
|
483
|
+
onclick: () => sendControlValue(control.id, { enabled: true, mode: 'rain' }),
|
|
484
|
+
}, "Rain")
|
|
485
|
+
)
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* No-Transmit Zones control - 2 zone editors with enabled/start/end
|
|
491
|
+
* Server uses individual controls: noTransmitStart1/End1/Start2/End2
|
|
492
|
+
* Value of -1 means zone is disabled
|
|
493
|
+
*/
|
|
494
|
+
function buildNoTransmitZonesControl(control) {
|
|
495
|
+
// Read from individual controls (server uses flat model)
|
|
496
|
+
// -1 means zone is disabled
|
|
497
|
+
const z1Start = getControlValue('noTransmitStart1') ?? -1;
|
|
498
|
+
const z1End = getControlValue('noTransmitEnd1') ?? -1;
|
|
499
|
+
const z2Start = getControlValue('noTransmitStart2') ?? -1;
|
|
500
|
+
const z2End = getControlValue('noTransmitEnd2') ?? -1;
|
|
501
|
+
|
|
502
|
+
// -1 means disabled (value < 0)
|
|
503
|
+
const zone1 = {
|
|
504
|
+
enabled: z1Start >= 0 && z1End >= 0,
|
|
505
|
+
start: z1Start < 0 ? 0 : z1Start,
|
|
506
|
+
end: z1End < 0 ? 0 : z1End
|
|
507
|
+
};
|
|
508
|
+
const zone2 = {
|
|
509
|
+
enabled: z2Start >= 0 && z2End >= 0,
|
|
510
|
+
start: z2Start < 0 ? 0 : z2Start,
|
|
511
|
+
end: z2End < 0 ? 0 : z2End
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// Read current zone values from DOM (to avoid stale closure values)
|
|
515
|
+
function getZoneFromDOM(zoneNum) {
|
|
516
|
+
const prefix = `myr_ntz_zone${zoneNum}`;
|
|
517
|
+
const enabledEl = document.getElementById(`${prefix}_enabled`);
|
|
518
|
+
const startEl = document.getElementById(`${prefix}_start`);
|
|
519
|
+
const endEl = document.getElementById(`${prefix}_end`);
|
|
520
|
+
return {
|
|
521
|
+
enabled: enabledEl?.checked || false,
|
|
522
|
+
start: parseInt(startEl?.value) || 0,
|
|
523
|
+
end: parseInt(endEl?.value) || 0
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function sendCurrentZones() {
|
|
528
|
+
const z1 = getZoneFromDOM(1);
|
|
529
|
+
const z2 = getZoneFromDOM(2);
|
|
530
|
+
console.log('NTZ: Sending zones:', { z1, z2 });
|
|
531
|
+
|
|
532
|
+
// Send individual control values (server has noTransmitStart1/End1/Start2/End2)
|
|
533
|
+
// When zone is disabled, send -1 for both angles (server convention for disabled)
|
|
534
|
+
const z1Start = z1.enabled ? z1.start : -1;
|
|
535
|
+
const z1End = z1.enabled ? z1.end : -1;
|
|
536
|
+
const z2Start = z2.enabled ? z2.start : -1;
|
|
537
|
+
const z2End = z2.enabled ? z2.end : -1;
|
|
538
|
+
|
|
539
|
+
// Send all four controls using sendControlValue to get pending tracking
|
|
540
|
+
sendControlValue('noTransmitStart1', z1Start);
|
|
541
|
+
sendControlValue('noTransmitEnd1', z1End);
|
|
542
|
+
sendControlValue('noTransmitStart2', z2Start);
|
|
543
|
+
sendControlValue('noTransmitEnd2', z2End);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildZoneEditor(zoneNum, zone) {
|
|
547
|
+
const prefix = `myr_ntz_zone${zoneNum}`;
|
|
548
|
+
|
|
549
|
+
// Handler for checkbox change - enable/disable inputs and send
|
|
550
|
+
function onEnabledChange(e) {
|
|
551
|
+
const enabled = e.target.checked;
|
|
552
|
+
const startEl = document.getElementById(`${prefix}_start`);
|
|
553
|
+
const endEl = document.getElementById(`${prefix}_end`);
|
|
554
|
+
if (startEl) startEl.disabled = !enabled;
|
|
555
|
+
if (endEl) endEl.disabled = !enabled;
|
|
556
|
+
sendCurrentZones();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return div({ class: "myr_ntz_zone" },
|
|
560
|
+
div({ class: "myr_ntz_zone_header" },
|
|
561
|
+
label({ class: "myr_checkbox_label" },
|
|
562
|
+
input({
|
|
563
|
+
type: "checkbox",
|
|
564
|
+
id: `${prefix}_enabled`,
|
|
565
|
+
checked: zone.enabled,
|
|
566
|
+
onchange: onEnabledChange
|
|
567
|
+
}),
|
|
568
|
+
` Zone ${zoneNum}`
|
|
569
|
+
)
|
|
570
|
+
),
|
|
571
|
+
div({ class: "myr_ntz_angles" },
|
|
572
|
+
div({ class: "myr_ntz_angle" },
|
|
573
|
+
label({ for: `${prefix}_start` }, "Start°"),
|
|
574
|
+
input({
|
|
575
|
+
type: "number",
|
|
576
|
+
id: `${prefix}_start`,
|
|
577
|
+
min: 0,
|
|
578
|
+
max: 359,
|
|
579
|
+
value: zone.start,
|
|
580
|
+
disabled: !zone.enabled,
|
|
581
|
+
onchange: () => sendCurrentZones()
|
|
582
|
+
})
|
|
583
|
+
),
|
|
584
|
+
div({ class: "myr_ntz_angle" },
|
|
585
|
+
label({ for: `${prefix}_end` }, "End°"),
|
|
586
|
+
input({
|
|
587
|
+
type: "number",
|
|
588
|
+
id: `${prefix}_end`,
|
|
589
|
+
min: 0,
|
|
590
|
+
max: 359,
|
|
591
|
+
value: zone.end,
|
|
592
|
+
disabled: !zone.enabled,
|
|
593
|
+
onchange: () => sendCurrentZones()
|
|
594
|
+
})
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return div({ class: "myr_control myr_ntz_control", id: `myr_${control.id}_compound` },
|
|
601
|
+
span({ class: "myr_control_label" }, control.name),
|
|
602
|
+
div({ class: "myr_ntz_zones" },
|
|
603
|
+
buildZoneEditor(1, zone1),
|
|
604
|
+
buildZoneEditor(2, zone2)
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Read-only info control
|
|
611
|
+
*/
|
|
612
|
+
function buildInfoControl(control) {
|
|
613
|
+
const value = getControlValue(control.id) || '-';
|
|
614
|
+
|
|
615
|
+
return div({ class: "myr_control myr_info_control" },
|
|
616
|
+
span({ class: "myr_control_label" }, control.name),
|
|
617
|
+
span({ id: `myr_${control.id}`, class: "myr_info_value" }, formatInfoValue(value, control))
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// Control Value Helpers
|
|
623
|
+
// ============================================================================
|
|
624
|
+
|
|
625
|
+
function getControlValue(controlId) {
|
|
626
|
+
return radarState?.controls?.[controlId];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function formatNumberValue(value, control) {
|
|
630
|
+
// Handle compound values (objects with mode/value)
|
|
631
|
+
let numValue = value;
|
|
632
|
+
if (typeof value === 'object' && value !== null) {
|
|
633
|
+
if (value.mode === 'auto') {
|
|
634
|
+
return 'Auto';
|
|
635
|
+
}
|
|
636
|
+
numValue = value.value !== undefined ? value.value : 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const unit = control?.range?.unit || '';
|
|
640
|
+
if (unit === 'percent') {
|
|
641
|
+
return `${numValue}%`;
|
|
642
|
+
}
|
|
643
|
+
return unit ? `${numValue} ${unit}` : String(numValue);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function formatInfoValue(value, control) {
|
|
647
|
+
if (control.id === 'operatingHours' && typeof value === 'number') {
|
|
648
|
+
return `${value.toFixed(1)} hrs`;
|
|
649
|
+
}
|
|
650
|
+
return String(value);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function formatRange(meters) {
|
|
654
|
+
const nm = meters / 1852;
|
|
655
|
+
if (nm >= 1) {
|
|
656
|
+
if (nm === 1.5) return "1.5 nm";
|
|
657
|
+
return Math.round(nm) + " nm";
|
|
658
|
+
} else if (nm >= 0.7) {
|
|
659
|
+
return "3/4 nm";
|
|
660
|
+
} else if (nm >= 0.4) {
|
|
661
|
+
return "1/2 nm";
|
|
662
|
+
} else if (nm >= 0.2) {
|
|
663
|
+
return "1/4 nm";
|
|
664
|
+
} else if (nm >= 0.1) {
|
|
665
|
+
return "1/8 nm";
|
|
666
|
+
} else {
|
|
667
|
+
return "1/16 nm";
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function updateRangeDisplay() {
|
|
672
|
+
const display = document.getElementById("myr_range_display");
|
|
673
|
+
if (display) {
|
|
674
|
+
display.textContent = formatRange(currentRange);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ============================================================================
|
|
679
|
+
// Control Commands
|
|
680
|
+
// ============================================================================
|
|
681
|
+
|
|
682
|
+
async function sendControlValue(controlId, value) {
|
|
683
|
+
if (!radarId) return;
|
|
684
|
+
|
|
685
|
+
console.log(`Sending control: ${controlId} = ${JSON.stringify(value)}`);
|
|
686
|
+
|
|
687
|
+
// Mark as pending to prevent polling from overwriting
|
|
688
|
+
pendingControls[controlId] = { value, timestamp: Date.now() };
|
|
689
|
+
|
|
690
|
+
// Optimistic UI update immediately
|
|
691
|
+
updateControlUI(controlId, value);
|
|
692
|
+
|
|
693
|
+
const success = await setControl(radarId, controlId, value);
|
|
694
|
+
|
|
695
|
+
if (success) {
|
|
696
|
+
// Notify callbacks
|
|
697
|
+
const control = capabilities?.controls?.find(c => c.id === controlId);
|
|
698
|
+
controlCallbacks.forEach(cb => cb(control, { id: controlId, value }));
|
|
699
|
+
|
|
700
|
+
// Persist Installation category controls (write-only settings like bearingAlignment)
|
|
701
|
+
// Use capabilities.key (e.g., "Furuno-RD003212") for storage - compatible with WASM SignalK plugin
|
|
702
|
+
if (control?.category === 'installation') {
|
|
703
|
+
const storageKey = capabilities?.key || radarId;
|
|
704
|
+
saveInstallationSetting(storageKey, controlId, value);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function changeRange(direction) {
|
|
710
|
+
const ranges = capabilities?.characteristics?.supportedRanges || [];
|
|
711
|
+
if (ranges.length === 0) return;
|
|
712
|
+
|
|
713
|
+
// Use tracked index if valid, otherwise find from current range
|
|
714
|
+
if (userRequestedRangeIndex < 0 || userRequestedRangeIndex >= ranges.length) {
|
|
715
|
+
userRequestedRangeIndex = ranges.findIndex(r => Math.abs(r - currentRange) < 50);
|
|
716
|
+
if (userRequestedRangeIndex < 0) userRequestedRangeIndex = 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const newIndex = Math.max(0, Math.min(ranges.length - 1, userRequestedRangeIndex + direction));
|
|
720
|
+
const newRange = ranges[newIndex];
|
|
721
|
+
|
|
722
|
+
// Always update index to track user's position
|
|
723
|
+
userRequestedRangeIndex = newIndex;
|
|
724
|
+
|
|
725
|
+
sendControlValue('range', newRange);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ============================================================================
|
|
729
|
+
// UI Updates from State
|
|
730
|
+
// ============================================================================
|
|
731
|
+
|
|
732
|
+
function updateControlUI(controlId, value) {
|
|
733
|
+
// Update local state
|
|
734
|
+
if (radarState?.controls) {
|
|
735
|
+
radarState.controls[controlId] = value;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Update UI based on control type
|
|
739
|
+
const control = capabilities?.controls?.find(c => c.id === controlId);
|
|
740
|
+
if (!control) return;
|
|
741
|
+
|
|
742
|
+
// Special case for dopplerMode
|
|
743
|
+
if (controlId === 'dopplerMode') {
|
|
744
|
+
updateDopplerModeUI(controlId, value);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Special case for noTransmitZones (compound) or individual NTZ controls
|
|
749
|
+
if (controlId === 'noTransmitZones') {
|
|
750
|
+
updateNoTransmitZonesUI(value);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// Handle individual NTZ controls - update the compound UI
|
|
754
|
+
if (controlId.startsWith('noTransmit')) {
|
|
755
|
+
updateNoTransmitZoneFromIndividual(controlId, value);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
switch (control.type) {
|
|
760
|
+
case 'boolean':
|
|
761
|
+
updateBooleanUI(controlId, value);
|
|
762
|
+
break;
|
|
763
|
+
case 'number':
|
|
764
|
+
updateNumberUI(controlId, value, control);
|
|
765
|
+
break;
|
|
766
|
+
case 'enum':
|
|
767
|
+
updateEnumUI(controlId, value);
|
|
768
|
+
break;
|
|
769
|
+
case 'compound':
|
|
770
|
+
updateCompoundUI(controlId, value, control);
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Special handling for power
|
|
775
|
+
if (controlId === 'power') {
|
|
776
|
+
updatePowerUI(value);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function updatePowerUI(value) {
|
|
781
|
+
const transmitBtn = document.querySelector('.myr_power_button_transmit');
|
|
782
|
+
const standbyBtn = document.querySelector('.myr_power_button_standby');
|
|
783
|
+
|
|
784
|
+
if (transmitBtn) {
|
|
785
|
+
transmitBtn.classList.toggle('myr_power_active', value === 'transmit');
|
|
786
|
+
}
|
|
787
|
+
if (standbyBtn) {
|
|
788
|
+
standbyBtn.classList.toggle('myr_power_active', value === 'standby');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function updateBooleanUI(controlId, value) {
|
|
793
|
+
const btn = document.getElementById(`myr_${controlId}`);
|
|
794
|
+
if (btn) {
|
|
795
|
+
btn.classList.toggle('myr_toggle_active', value);
|
|
796
|
+
btn.textContent = value ? "On" : "Off";
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function updateNumberUI(controlId, value, control) {
|
|
801
|
+
const slider = document.getElementById(`myr_${controlId}`);
|
|
802
|
+
const valueEl = document.getElementById(`myr_${controlId}_value`);
|
|
803
|
+
|
|
804
|
+
if (slider) {
|
|
805
|
+
slider.value = value;
|
|
806
|
+
}
|
|
807
|
+
if (valueEl) {
|
|
808
|
+
valueEl.textContent = formatNumberValue(value, control);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function updateEnumUI(controlId, value) {
|
|
813
|
+
const group = document.getElementById(`myr_${controlId}_group`);
|
|
814
|
+
if (group) {
|
|
815
|
+
// Convert value to string for comparison (dataset values are always strings)
|
|
816
|
+
const valueStr = String(value);
|
|
817
|
+
group.querySelectorAll('.myr_enum_button').forEach(btn => {
|
|
818
|
+
btn.classList.toggle('myr_enum_active', btn.dataset.value === valueStr);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function updateCompoundUI(controlId, value, control) {
|
|
824
|
+
const compound = document.getElementById(`myr_${controlId}_compound`);
|
|
825
|
+
if (!compound) return;
|
|
826
|
+
|
|
827
|
+
const mode = value?.mode || 'auto';
|
|
828
|
+
const val = value?.value;
|
|
829
|
+
|
|
830
|
+
// Update mode buttons
|
|
831
|
+
compound.querySelectorAll('.myr_mode_button').forEach(btn => {
|
|
832
|
+
btn.classList.toggle('myr_mode_active', btn.dataset.mode === mode);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Update slider
|
|
836
|
+
const slider = compound.querySelector('.myr_compound_slider');
|
|
837
|
+
const valueEl = document.getElementById(`myr_${controlId}_value`);
|
|
838
|
+
|
|
839
|
+
const isAuto = mode === 'auto';
|
|
840
|
+
if (slider) {
|
|
841
|
+
slider.disabled = isAuto;
|
|
842
|
+
if (val !== undefined) {
|
|
843
|
+
slider.value = val;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (valueEl) {
|
|
847
|
+
valueEl.textContent = isAuto ? "Auto" : (val !== undefined ? val : '-');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function updateDopplerModeUI(controlId, value) {
|
|
852
|
+
const compound = document.getElementById(`myr_${controlId}_compound`);
|
|
853
|
+
if (!compound) return;
|
|
854
|
+
|
|
855
|
+
const enabled = value?.enabled || false;
|
|
856
|
+
const mode = value?.mode || 'target';
|
|
857
|
+
const activeBtn = !enabled ? 'off' : mode;
|
|
858
|
+
|
|
859
|
+
// Update buttons (Off / Target / Rain)
|
|
860
|
+
compound.querySelectorAll('.myr_mode_button').forEach(btn => {
|
|
861
|
+
btn.classList.toggle('myr_mode_active', btn.dataset.value === activeBtn);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function updateNoTransmitZonesUI(value) {
|
|
866
|
+
const zones = value?.zones || [];
|
|
867
|
+
const zone1 = zones[0] || { enabled: false, start: 0, end: 0 };
|
|
868
|
+
const zone2 = zones[1] || { enabled: false, start: 0, end: 0 };
|
|
869
|
+
|
|
870
|
+
// Update zone 1
|
|
871
|
+
const z1Enabled = document.getElementById('myr_ntz_zone1_enabled');
|
|
872
|
+
const z1Start = document.getElementById('myr_ntz_zone1_start');
|
|
873
|
+
const z1End = document.getElementById('myr_ntz_zone1_end');
|
|
874
|
+
if (z1Enabled) z1Enabled.checked = zone1.enabled;
|
|
875
|
+
if (z1Start) {
|
|
876
|
+
z1Start.value = zone1.start;
|
|
877
|
+
z1Start.disabled = !zone1.enabled;
|
|
878
|
+
}
|
|
879
|
+
if (z1End) {
|
|
880
|
+
z1End.value = zone1.end;
|
|
881
|
+
z1End.disabled = !zone1.enabled;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Update zone 2
|
|
885
|
+
const z2Enabled = document.getElementById('myr_ntz_zone2_enabled');
|
|
886
|
+
const z2Start = document.getElementById('myr_ntz_zone2_start');
|
|
887
|
+
const z2End = document.getElementById('myr_ntz_zone2_end');
|
|
888
|
+
if (z2Enabled) z2Enabled.checked = zone2.enabled;
|
|
889
|
+
if (z2Start) {
|
|
890
|
+
z2Start.value = zone2.start;
|
|
891
|
+
z2Start.disabled = !zone2.enabled;
|
|
892
|
+
}
|
|
893
|
+
if (z2End) {
|
|
894
|
+
z2End.value = zone2.end;
|
|
895
|
+
z2End.disabled = !zone2.enabled;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Update NTZ UI from individual control updates (noTransmitStart1, etc.)
|
|
901
|
+
* Server uses flat model with -1 meaning disabled
|
|
902
|
+
*/
|
|
903
|
+
function updateNoTransmitZoneFromIndividual(controlId, value) {
|
|
904
|
+
// Parse control ID: noTransmitStart1, noTransmitEnd1, noTransmitStart2, noTransmitEnd2
|
|
905
|
+
const match = controlId.match(/noTransmit(Start|End)(\d)/);
|
|
906
|
+
if (!match) return;
|
|
907
|
+
|
|
908
|
+
const [, type, zoneNum] = match;
|
|
909
|
+
const prefix = `myr_ntz_zone${zoneNum}`;
|
|
910
|
+
const isStart = type === 'Start';
|
|
911
|
+
|
|
912
|
+
// -1 means zone is disabled (value < 0)
|
|
913
|
+
const isDisabled = value < 0;
|
|
914
|
+
const displayValue = isDisabled ? 0 : value;
|
|
915
|
+
|
|
916
|
+
// Update the angle input
|
|
917
|
+
const inputEl = document.getElementById(`${prefix}_${isStart ? 'start' : 'end'}`);
|
|
918
|
+
if (inputEl) {
|
|
919
|
+
inputEl.value = displayValue;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Check if both start and end are >= 0 to determine enabled state
|
|
923
|
+
// Use pending values if available, otherwise fall back to state
|
|
924
|
+
const startId = `noTransmitStart${zoneNum}`;
|
|
925
|
+
const endId = `noTransmitEnd${zoneNum}`;
|
|
926
|
+
const startVal = pendingControls[startId]?.value ?? getControlValue(startId) ?? -1;
|
|
927
|
+
const endVal = pendingControls[endId]?.value ?? getControlValue(endId) ?? -1;
|
|
928
|
+
const zoneEnabled = startVal >= 0 && endVal >= 0;
|
|
929
|
+
|
|
930
|
+
// Update enabled checkbox and input disabled states
|
|
931
|
+
const enabledEl = document.getElementById(`${prefix}_enabled`);
|
|
932
|
+
const startEl = document.getElementById(`${prefix}_start`);
|
|
933
|
+
const endEl = document.getElementById(`${prefix}_end`);
|
|
934
|
+
|
|
935
|
+
if (enabledEl) enabledEl.checked = zoneEnabled;
|
|
936
|
+
if (startEl) startEl.disabled = !zoneEnabled;
|
|
937
|
+
if (endEl) endEl.disabled = !zoneEnabled;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function applyStateToUI(state) {
|
|
941
|
+
if (!state?.controls) return;
|
|
942
|
+
|
|
943
|
+
for (const [controlId, value] of Object.entries(state.controls)) {
|
|
944
|
+
// Skip controls with pending changes until server confirms the same value
|
|
945
|
+
const pending = pendingControls[controlId];
|
|
946
|
+
if (pending) {
|
|
947
|
+
// Check if server has confirmed our pending value
|
|
948
|
+
const serverValue = JSON.stringify(value);
|
|
949
|
+
const pendingValue = JSON.stringify(pending.value);
|
|
950
|
+
if (serverValue === pendingValue) {
|
|
951
|
+
// Server confirmed, clear pending
|
|
952
|
+
delete pendingControls[controlId];
|
|
953
|
+
} else {
|
|
954
|
+
// Server hasn't confirmed yet, keep user's value
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
updateControlUI(controlId, value);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Update range display and initialize range index
|
|
962
|
+
// Skip if we already have range from spoke data (more accurate than state API)
|
|
963
|
+
if (state.controls.range && !rangeFromSpokeData) {
|
|
964
|
+
currentRange = state.controls.range;
|
|
965
|
+
// Initialize userRequestedRangeIndex from actual radar range
|
|
966
|
+
const ranges = capabilities?.characteristics?.supportedRanges || [];
|
|
967
|
+
userRequestedRangeIndex = ranges.findIndex(r => Math.abs(r - currentRange) < 50);
|
|
968
|
+
if (userRequestedRangeIndex < 0) userRequestedRangeIndex = 0;
|
|
969
|
+
updateRangeDisplay();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ============================================================================
|
|
974
|
+
// State Polling
|
|
975
|
+
// ============================================================================
|
|
976
|
+
|
|
977
|
+
let pollFailCount = 0;
|
|
978
|
+
const MAX_POLL_INTERVAL = 30000; // Max 30s between polls on repeated failures
|
|
979
|
+
const BASE_POLL_INTERVAL = 2000; // Normal 2s polling
|
|
980
|
+
|
|
981
|
+
async function pollState() {
|
|
982
|
+
if (!radarId) return;
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
const newState = await fetchState(radarId);
|
|
986
|
+
if (newState) {
|
|
987
|
+
radarState = newState;
|
|
988
|
+
applyStateToUI(radarState);
|
|
989
|
+
|
|
990
|
+
// Reset fail count on success and restore normal polling
|
|
991
|
+
if (pollFailCount > 0) {
|
|
992
|
+
pollFailCount = 0;
|
|
993
|
+
startStatePolling(); // Restart with normal interval
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} catch (err) {
|
|
997
|
+
pollFailCount++;
|
|
998
|
+
if (pollFailCount <= 3) {
|
|
999
|
+
console.warn(`State poll failed (${pollFailCount}):`, err.message);
|
|
1000
|
+
} else if (pollFailCount === 4) {
|
|
1001
|
+
console.warn("State polling failing, backing off...");
|
|
1002
|
+
}
|
|
1003
|
+
// Exponential backoff: restart polling with longer interval
|
|
1004
|
+
if (pollFailCount > 2) {
|
|
1005
|
+
startStatePolling();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function startStatePolling() {
|
|
1011
|
+
if (statePollingInterval) {
|
|
1012
|
+
clearInterval(statePollingInterval);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Exponential backoff on failures
|
|
1016
|
+
const interval = Math.min(BASE_POLL_INTERVAL * Math.pow(2, pollFailCount), MAX_POLL_INTERVAL);
|
|
1017
|
+
statePollingInterval = setInterval(pollState, interval);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function stopStatePolling() {
|
|
1021
|
+
if (statePollingInterval) {
|
|
1022
|
+
clearInterval(statePollingInterval);
|
|
1023
|
+
statePollingInterval = null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ============================================================================
|
|
1028
|
+
// Initialization (for standalone control.html only)
|
|
1029
|
+
// ============================================================================
|
|
1030
|
+
|
|
1031
|
+
// For control.html: auto-initialize on load
|
|
1032
|
+
// For viewer.html: viewer.js imports this module and calls loadRadar() itself
|
|
1033
|
+
// We detect standalone mode by checking if viewer.js has NOT registered a callback
|
|
1034
|
+
// (viewer.js calls registerRadarCallback before window.onload)
|
|
1035
|
+
setTimeout(() => {
|
|
1036
|
+
// If no callbacks registered after module evaluation, we're in standalone mode
|
|
1037
|
+
if (callbacks.length === 0) {
|
|
1038
|
+
window.onload = function() {
|
|
1039
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1040
|
+
const id = urlParams.get("id");
|
|
1041
|
+
loadRadar(id);
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}, 0);
|
|
1045
|
+
|
|
1046
|
+
async function loadRadar(id) {
|
|
1047
|
+
try {
|
|
1048
|
+
await detectMode();
|
|
1049
|
+
|
|
1050
|
+
// If no ID provided, get first radar
|
|
1051
|
+
if (!id) {
|
|
1052
|
+
const ids = await fetchRadarIds();
|
|
1053
|
+
if (ids.length > 0) {
|
|
1054
|
+
id = ids[0];
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!id) {
|
|
1059
|
+
console.error("No radar found");
|
|
1060
|
+
showError("No radar found. Please check connection.");
|
|
1061
|
+
setTimeout(() => loadRadar(null), 10000);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
radarId = id;
|
|
1066
|
+
console.log(`Loading radar: ${radarId}`);
|
|
1067
|
+
|
|
1068
|
+
// Fetch capabilities
|
|
1069
|
+
capabilities = await fetchCapabilities(radarId);
|
|
1070
|
+
console.log("Capabilities:", capabilities);
|
|
1071
|
+
|
|
1072
|
+
// Fetch initial state
|
|
1073
|
+
radarState = await fetchState(radarId);
|
|
1074
|
+
console.log("Initial state:", radarState);
|
|
1075
|
+
|
|
1076
|
+
// Build UI
|
|
1077
|
+
buildControlsFromCapabilities();
|
|
1078
|
+
|
|
1079
|
+
// Start polling for state updates
|
|
1080
|
+
startStatePolling();
|
|
1081
|
+
|
|
1082
|
+
// Notify callbacks (viewer.js expects these properties)
|
|
1083
|
+
const chars = capabilities.characteristics || {};
|
|
1084
|
+
|
|
1085
|
+
// Build streamUrl based on mode
|
|
1086
|
+
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
1087
|
+
let streamUrl;
|
|
1088
|
+
if (isStandaloneMode()) {
|
|
1089
|
+
// Standalone mode: use /v2/api/radars/{id}/spokes
|
|
1090
|
+
streamUrl = `${wsProtocol}//${window.location.host}/v2/api/radars/${radarId}/spokes`;
|
|
1091
|
+
} else {
|
|
1092
|
+
// SignalK mode: use /signalk/v2/api/vessels/self/radars/{id}/stream
|
|
1093
|
+
streamUrl = `${wsProtocol}//${window.location.host}/signalk/v2/api/vessels/self/radars/${radarId}/stream`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
callbacks.forEach(cb => cb({
|
|
1097
|
+
id: radarId,
|
|
1098
|
+
name: `${capabilities.make} ${capabilities.model}`,
|
|
1099
|
+
maxSpokeLen: chars.maxSpokeLength || 512,
|
|
1100
|
+
spokesPerRevolution: chars.spokesPerRevolution || 2048,
|
|
1101
|
+
controls: capabilities.controls || [],
|
|
1102
|
+
capabilities,
|
|
1103
|
+
state: radarState,
|
|
1104
|
+
streamUrl,
|
|
1105
|
+
}));
|
|
1106
|
+
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
console.error("Failed to load radar:", err);
|
|
1109
|
+
showError(`Failed to load radar: ${err.message}`);
|
|
1110
|
+
setTimeout(() => loadRadar(id), 10000);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function showError(message) {
|
|
1115
|
+
const errorEl = document.getElementById("myr_error");
|
|
1116
|
+
if (errorEl) {
|
|
1117
|
+
errorEl.textContent = message;
|
|
1118
|
+
errorEl.style.visibility = "visible";
|
|
1119
|
+
setTimeout(() => {
|
|
1120
|
+
errorEl.style.visibility = "hidden";
|
|
1121
|
+
}, 5000);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Get current power state
|
|
1127
|
+
* @returns {string} 'standby' | 'transmit' | 'off' | 'warming'
|
|
1128
|
+
*/
|
|
1129
|
+
function getPowerState() {
|
|
1130
|
+
return radarState?.controls?.power || 'standby';
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Get operating hours from radar state
|
|
1135
|
+
* @returns {{ onTime: number, txTime: number }} Operating hours
|
|
1136
|
+
*/
|
|
1137
|
+
function getOperatingHours() {
|
|
1138
|
+
const controls = radarState?.controls || {};
|
|
1139
|
+
return {
|
|
1140
|
+
onTime: controls.operatingHours || 0,
|
|
1141
|
+
txTime: controls.transmitHours || 0
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Check if radar has hours capability (operatingHours or transmitHours)
|
|
1147
|
+
* @returns {{ hasOnTime: boolean, hasTxTime: boolean }}
|
|
1148
|
+
*/
|
|
1149
|
+
function hasHoursCapability() {
|
|
1150
|
+
const controls = capabilities?.controls || [];
|
|
1151
|
+
return {
|
|
1152
|
+
hasOnTime: controls.some(c => c.id === 'operatingHours'),
|
|
1153
|
+
hasTxTime: controls.some(c => c.id === 'transmitHours')
|
|
1154
|
+
};
|
|
1155
|
+
}
|