signalk-racer 0.9.5 → 0.9.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Signal K Racing Plugin
2
2
 
3
- This PROTOTYPPE plugin adds sail racing-relevant data calculations to a Signal K server. It focuses on real-time information useful during the start of a race, such as distance to the line, start line bias, and geographic position of the line ends.
3
+ This PROTOTYPE plugin adds sail racing-relevant data calculations to a Signal K server. It focuses on real-time information useful during the start of a race, such as distance to the line, start line bias, and geographic position of the line ends.
4
4
 
5
5
  GitHub repository: [https://github.com/gregw/signalk-racer](https://github.com/gregw/signalk-racer)
6
6
 
@@ -10,17 +10,19 @@ GitHub repository: [https://github.com/gregw/signalk-racer](https://github.com/g
10
10
 
11
11
  This plugin calculates and publishes the following Signal K paths:
12
12
 
13
- | Path | Description | Units | Schema |
14
- |---------------------------------------------|---------------------------------------------------------|---------|--------|
15
- | `navigation.racing.`<br/>`distanceStartline` | Signed minimum distance from the bow to the start line | `m` | Y |
16
- | `navigation.racing.`<br/>`startLineLength` | Total length of the start line | `m` | |
17
- | `navigation.racing.`<br/>`stbLineBias` | Bias of the start line toward the starboard end | `m` | |
18
- | `navigation.racing.`<br/>`startLinePort.latitude` | Latitude of the port (pin) end of the start line | `deg` | Y |
19
- | `navigation.racing.`<br/>`startLinePort.longitude` | Longitude of the port (pin) end of the start line | `deg` | Y |
20
- | `navigation.racing.`<br/>`startLineStb.latitude` | Latitude of the starboard (boat) end of the start line | `deg` | Y |
21
- | `navigation.racing.`<br/>`startLineStb.longitude` | Longitude of the starboard (boat) end of the start line | `deg` | Y |
22
- | `navigation.racing.`<br/>`nextLegHeading` | True heading for the next leg of the course | `deg` | |
23
- | `navigation.racing.`<br/>`nextLegTWA` | True Wind Angle for the next leg of the course | `deg` | |
13
+ | Path | Description | Units | Std |
14
+ |----------------------------------------------------|---------------------------------------------------------|-------|-----|
15
+ | `navigation.racing.`<br/>`distanceStartline` | Signed minimum distance from the bow to the start line | `m` | Y |
16
+ | `navigation.racing.`<br/>`startLineLength` | Total length of the start line | `m` | |
17
+ | `navigation.racing.`<br/>`stbLineBias` | Bias of the start line toward the starboard end | `m` | |
18
+ | `navigation.racing.`<br/>`startLinePort.latitude` | Latitude of the port (pin) end of the start line | `deg` | Y |
19
+ | `navigation.racing.`<br/>`startLinePort.longitude` | Longitude of the port (pin) end of the start line | `deg` | Y |
20
+ | `navigation.racing.`<br/>`startLineStb.latitude` | Latitude of the starboard (boat) end of the start line | `deg` | Y |
21
+ | `navigation.racing.`<br/>`startLineStb.longitude` | Longitude of the starboard (boat) end of the start line | `deg` | Y |
22
+ | `navigation.racing.`<br/>`nextLegHeading` | True heading for the next leg of the course | `deg` | |
23
+ | `navigation.racing.`<br/>`nextLegTWA` | True Wind Angle for the next leg of the course | `deg` | |
24
+ | `navigation.racing.`<br/>`timeToStart` | Period of time until the race start | `s` | Y |
25
+ | `navigation.racing.`<br/>`startTime` | The start time as an ISO timestamp | `iso` | |
24
26
 
25
27
  These values can be displayed in KIP, Freeboard-SK, or other Signal K clients.
26
28
 
@@ -28,18 +30,17 @@ These values can be displayed in KIP, Freeboard-SK, or other Signal K clients.
28
30
 
29
31
  ---
30
32
 
31
-
32
33
  ## ⚙️ Configuration
33
34
 
34
35
  You can configure the plugin via the Signal K web interface or by editing `settings.json` manually.
35
36
 
36
37
  ### Parameters:
37
38
 
38
- | Parameter | Description | Default |
39
- |--------------------|---------------------------------------------------------------------------------------|---------------|
40
- | `startLineStb` | Name of the waypoint for the starboard (committee boat) end of the line | `"startBoat"` |
41
- | `startLinePort` | Name of the waypoint for the port (pin) end of the line | `"startPin"` |
42
- | `period` | How often to update values (in milliseconds) | `1000` |
39
+ | Parameter | Description | Default |
40
+ |----------------|--------------------------------------------------------------------------|---------------|
41
+ | `startLineStb` | Name of the waypoint for the starboard (committee boat) end of the line | `"startBoat"` |
42
+ | `startLinePort` | Name of the waypoint for the port (pin) end of the line | `"startPin"` |
43
+ | `period` | How often to update values (in milliseconds) | `1000` |
43
44
 
44
45
  ---
45
46
 
@@ -83,26 +84,48 @@ If `navigation.headingTrue` is not available, then `navigation.courseOverGroundT
83
84
 
84
85
  ---
85
86
 
86
- ## 🌐 Start Line REST API
87
+ ## 🌐 API Access
88
+
89
+ This plugin uses WebSocket-based PUT requests to Signal K model paths.
90
+
91
+ ### `navigation.racing.setStartLine`
87
92
 
88
- The plugin provides a REST API that allows external clients (e.g. KIP dashboards, mobile apps, or scripts) to **set or query the start line ends**. This can be used to "ping" the boat or pin end by calling a simple HTTP POST with or without a position.
93
+ Used to **set** or **adjust** either end of the start line.
89
94
 
90
- The plugin supports **partial line configuration**: you can set just one end (port or starboard), and it will store and emit that end's position immediately. Once both ends are defined, the full line geometry (length, bearing, bias) will be calculated.
95
+ #### Payload:
96
+ ```json
97
+ {
98
+ "end": "port" | "stb",
99
+ "position": "bow" | { "latitude": ..., "longitude": ... },
100
+ "delta": 10,
101
+ "rotate": 0.1
102
+ }
103
+ ```
91
104
 
92
- ### 📮 API Endpoints
105
+ - `end`: which end to modify.
106
+ - `position`: `"bow"` or `{ latitude, longitude }`.
107
+ - `delta`: distance in meters along bearing.
108
+ - `rotate`: angle in radians.
109
+
110
+ ---
93
111
 
94
- | Method | Endpoint | Description |
95
- |--------|-----------------------------------------|------------------------------------------------------------------------------------------------------|
96
- | `GET` | `/plugins/signalk-racer/startline/port` | Get the last known **port (pin)** end position |
97
- | `PUT` | `/plugins/signalk-racer/startline/port` | Set the **port (pin)** end of the start line to the current position of the boat plus the bow offset |
98
- | `GET` | `/plugins/signalk-racer/startline/stb` | Get the last known **starboard (boat)** end position |
99
- | `PUT` | `/plugins/signalk-racer/startline/stb` | Set the **starboard (boat)** end of the line to the current position of the boat plus the bow offset |
100
- | `PUT` | `/plugins/signalk-racer/timeToStart` | Set the **timeToStart** in seconds and receive the current **distanceToLine** |
112
+ ### `navigation.racing.setStartTime`
101
113
 
114
+ Used to **start**, **sync**, **reset**, or **set a fixed start time**.
115
+
116
+ #### Payload:
117
+ ```json
118
+ {
119
+ "command": "start" | "reset" | "sync" | "adjust" | "set",
120
+ "delta": 30,
121
+ "startTime": "2025-06-18T04:15:00Z"
122
+ }
123
+ ```
124
+ ---
102
125
 
103
126
  ## 🔄 Dependencies
104
127
 
105
- - [geolib](https://github.com/manuelbieh/geolib): Used for all geospatial math, including distances, bearings, and projections.
128
+ - `geolib`: for distance and bearing calculations.
106
129
 
107
130
  ---
108
131
 
@@ -111,11 +134,10 @@ The plugin supports **partial line configuration**: you can set just one end (po
111
134
  - Add calculations for:
112
135
  - **Time to line** (using STW and heading or polar speeds)
113
136
  - **Laylines** and distance/time to them
114
- - Include webapp UI for on-deck ease of "pinging" the line
137
+ - A KIP widget as well as the webapp.
115
138
  - Better integration with **Freeboard-SK** and **KIP** dashboards
116
139
 
117
- ---
118
-
140
+ ---
119
141
  ## 📬 Feedback
120
142
 
121
143
  Bug reports and suggestions are welcome at
package/ReleaseNotes.md CHANGED
@@ -10,4 +10,9 @@
10
10
  ## 0.9.4 Trivial rename
11
11
  + Renamed README.md for npmjs
12
12
  ## 0.9.5 Restructured
13
- + Restructured the plugin to avoid initialization problem in new server
13
+ + Restructured the plugin to avoid initialization problem in new server
14
+ ## 0.9.6 V2 API and Timer
15
+ + Added new API V2 methods
16
+ + Removed V1 methods
17
+ + Webapp uses websocket
18
+ + Server side timer
package/index.js CHANGED
@@ -1,9 +1,8 @@
1
- const geolib = require("geolib");
2
1
  module.exports = (app) => {
3
2
  const state = {};
4
3
  const geolib = require('geolib')
5
4
  const racerSchema = {
6
- title: 'Set System Time with sudo',
5
+ title: 'Signalk Racer Configuration',
7
6
  type: 'object',
8
7
  properties: {
9
8
  startLineStb: {
@@ -21,23 +20,11 @@ module.exports = (app) => {
21
20
  title: 'Update period in micro seconds',
22
21
  default: 1000
23
22
  },
24
- /* TODO
25
- speedToLine: {
23
+ timer: {
26
24
  type: 'number',
27
- title: 'The percentage of the best STW seen during the start period to use when calculating the time to the start line. This assumes the boat can sail slower than its best speed at most wind angles. Or -1 to use the current STW',
28
- default: 80
25
+ title: 'Timer default period in seconds',
26
+ default: 300
29
27
  },
30
- twaUpToLine: {
31
- type: 'number',
32
- title: 'The upwind TWA to use when calculating the time to the line, or -1 to use the best achieved during the start period',
33
- default: 45
34
- },
35
- twaDownToLine: {
36
- type: 'number',
37
- title: 'The downwind TWA to use when calculating the time to the line, or -1 to use the best achieved during the start period',
38
- default: 155
39
- },
40
- */
41
28
  }
42
29
  }
43
30
 
@@ -159,108 +146,277 @@ module.exports = (app) => {
159
146
  return bow;
160
147
  }
161
148
 
162
- async function setTimeToStart(req, res) {
163
- try {
164
- const body = req.body;
165
- if (!body || typeof body.timeToStart !== 'number') {
166
- res.status(400).json({ error: 'Missing or invalid `timeToStart`' });
167
- return;
168
- }
169
-
170
- const timeToStart = body.timeToStart; // value in seconds, or whatever unit you agree on
171
- app.debug(`Setting timeToStart to ${timeToStart}`);
172
-
173
- sendDeltas([
174
- {path: 'navigation.racing.timeToStart', value: timeToStart},
175
- ]);
176
-
177
- res.json({
178
- message: 'OK',
179
- timeToStart,
180
- distanceToLine: state.distanceToLine ? state.distanceToLine : null,
181
- });
182
- } catch (err) {
183
- app.error('Failed to set time to start:', err);
184
- res.status(500).json({ error: 'Failed to set time to start' });
185
- }
149
+ function camelCase(name) {
150
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
186
151
  }
187
152
 
188
- async function setStartLineEnd(req, res) {
153
+ async function setStartLine(context, path, value, cb) {
189
154
  try {
190
- const end = req.params.end; // "port" or "stb"
155
+ app.debug('setStartLine:', JSON.stringify(value));
156
+ if (!value || !value.end || typeof value.end !== 'string') {
157
+ app.error('Failed to set start line: !value.end');
158
+ cb(null, {state: 'FAILED'});
159
+ }
160
+ const end = value.end.toLowerCase();
191
161
  if (end !== 'port' && end !== 'stb') {
192
- res.status(400).send({error: 'End must be "port" or "stb"'});
193
- return;
162
+ app.error('Failed to set start line: unknown end');
163
+ cb(null, {state: 'FAILED'});
194
164
  }
195
165
 
196
- let position = app.getSelfPath('navigation.position');
197
- if (position)
198
- position = position.value;
199
- else {
200
- res.status(500).send({error: 'No valid position available'});
201
- return;
166
+ // Get the position either as arg or of the current bow postion
167
+ let position = value.position;
168
+ if (position === 'bow') {
169
+ position = app.getSelfPath('navigation.position');
170
+ if (position)
171
+ position = bowPosition(position.value);
202
172
  }
203
- const bow = bowPosition(position);
173
+
174
+ app.debug(`setStartLine[${end}] = ${JSON.stringify(position)} before translation/rotation`);
204
175
  const startLine = state.startLine;
205
- const waypointName = state.options[`startLine${end.charAt(0).toUpperCase() + end.slice(1)}`];
206
-
207
- let waypointId = startLine ? startLine[end + 'Id'] : null;
208
- if (!waypointId) {
209
- // Get the ID of the last existing waypoint with the given name.
210
- const waypoints = await app.resourcesApi.listResources('waypoints', {});
211
- for (const [id, resource] of Object.entries(waypoints)) {
212
- if (resource.name === waypointName) {
213
- waypointId = id;
214
- // don't break here as we want the last waypoint with the given name
215
- }
216
- }
176
+
177
+ // Do any rotations or length changes
178
+ if (startLine && (value.delta || value.rotate)) {
179
+ app.debug(`setStartLine[${end}] translate ${value.delta} and rotate ${value.rotate} of ${JSON.stringify(startLine)}`);
180
+ const fixedEnd = startLine[end === 'port' ? 'stb' : 'port'];
181
+ const movingEnd = position ? position : startLine[end];
182
+ app.debug(`fixedEnd: ${JSON.stringify(fixedEnd)} movingEnd: ${JSON.stringify(movingEnd)}`);
183
+
184
+ const initialBearing = geolib.getRhumbLineBearing(fixedEnd, movingEnd);
185
+ const currentDistance = geolib.getDistance(fixedEnd, movingEnd);
186
+ app.debug(`initialBearing: ${initialBearing} currentDistance: ${currentDistance}`);
187
+
188
+ const newDistance = value.delta && typeof value.delta === 'number' ? (currentDistance + value.delta) : currentDistance;
189
+ const newBearing = value.rotate && typeof value.rotate === 'number'? (initialBearing + toDegrees(value.rotate)) : initialBearing;
190
+ app.debug(`newDistance: ${newDistance} newBearing: ${newBearing}`);
191
+
192
+ const newPosition = geolib.computeDestinationPoint(fixedEnd, newDistance, newBearing);
193
+ app.debug(`Moved/Rotated ${end} from ${JSON.stringify(position)} to ${JSON.stringify(newPosition)}`);
194
+ position = newPosition;
217
195
  }
218
196
 
219
- app.debug(`POSITION to set ${end}(${waypointName}/${waypointId}): ${JSON.stringify(bow)}`);
197
+ if (position) {
198
+ app.debug('position:' + JSON.stringify(position));
199
+ const waypointConfig =`startLine${camelCase(end)}`;
200
+ app.debug(`waypointConfig: ${waypointConfig}`);
201
+ const waypointName = state.options[waypointConfig];
202
+ app.debug(`waypointName: ${waypointName}`);
203
+ let waypointId = startLine ? startLine[end + 'Id'] : null;
204
+ app.debug(`waypointId: ${waypointId}`);
205
+
206
+ if (!waypointId) {
207
+ // Get the ID of the last existing waypoint with the given name.
208
+ const waypoints = await app.resourcesApi.listResources('waypoints', {});
209
+ for (const [id, resource] of Object.entries(waypoints)) {
210
+ if (resource.name === waypointName) {
211
+ waypointId = id;
212
+ // don't break here as we want the last waypoint with the given name
213
+ }
214
+ }
215
+ app.debug(`waypointId: ${waypointId}`);
216
+ }
220
217
 
221
- const waypoint = {
222
- name: waypointName,
223
- feature: {
224
- type: 'Feature',
225
- geometry: {
226
- type: 'Point',
227
- coordinates: [bow.longitude, bow.latitude]
228
- },
229
- properties: {}
218
+ app.debug(`POSITION to set ${end}(${waypointName}/${waypointId}): ${JSON.stringify(position)}`);
219
+
220
+ const waypoint = {
221
+ name: waypointName,
222
+ feature: {
223
+ type: 'Feature',
224
+ geometry: {
225
+ type: 'Point',
226
+ coordinates: [position.longitude, position.latitude]
227
+ },
228
+ properties: {}
229
+ }
230
230
  }
231
- }
232
231
 
233
- app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`);
232
+ app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`);
234
233
 
235
- if (waypointId)
236
- await app.resourcesApi.setResource('waypoints', waypointId, waypoint);
237
- else
238
- await app.resourcesApi.createResource('waypoints', waypoint);
234
+ if (waypointId)
235
+ await app.resourcesApi.setResource('waypoints', waypointId, waypoint);
236
+ else
237
+ await app.resourcesApi.createResource('waypoints', waypoint);
238
+ }
239
239
 
240
- res.json({
241
- message: 'OK'
240
+ cb(null, {
241
+ state: 'COMPLETED',
242
+ result: {
243
+ distanceToStart: state.distanceToLine ? state.distanceToLine : null,
244
+ }
242
245
  });
243
246
 
244
- if (startLine)
245
- {
246
- const newStartLine = { ...state.startLine };
247
+ if (startLine) {
248
+ const newStartLine = {...state.startLine};
247
249
  newStartLine[end + 'Id'] = waypointId;
248
- newStartLine[end] = bow;
250
+ newStartLine[end] = position;
249
251
  startLine.length = geolib.getPreciseDistance(newStartLine.port, newStartLine.stb, 0.1);
250
252
  startLine.bearing = geolib.getRhumbLineBearing(newStartLine.stb, newStartLine.port);
251
253
  state.startLine = newStartLine;
252
254
  sendDeltas([
253
- { path: `navigation.racing.startLine${end.charAt(0).toUpperCase() + end.slice(1)}`, value: bow },
254
- { path: 'navigation.racing.startLineLength', value: newStartLine.length },
255
+ {path: `navigation.racing.startLine${end.charAt(0).toUpperCase() + end.slice(1)}`, value: position},
256
+ {path: 'navigation.racing.startLineLength', value: newStartLine.length},
255
257
  ]);
256
258
 
257
- processPosition(position);
259
+ processPosition(null);
258
260
  } else {
259
- findLineAndThenProcess(position, true);
261
+ findLineAndThenProcess(null, true);
262
+ }
263
+ } catch (err) {
264
+ app.error('Failed to set start line end:', JSON.stringify(err));
265
+ cb(null, {state: 'FAILED'});
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ async function startTimeCommand(context, path, value, cb) {
271
+ // start / reset / sync
272
+ try {
273
+ app.debug('startTimerCommand:', JSON.stringify(value));
274
+ if (!value || !value.command || typeof value.command !== 'string') {
275
+ app.error('Failed to command start timer: ' + JSON.stringify(value));
276
+ cb(null, {state: 'FAILED'});
277
+ }
278
+ const command = value.command.toLowerCase();
279
+
280
+ switch (command)
281
+ {
282
+ case 'start': {
283
+ const now = new Date();
284
+ const timeToStart = state.timeToStart ?? state.options.timer ?? 300;
285
+ const startTimestamp = new Date(now.getTime() + timeToStart * 1000).toISOString();
286
+
287
+ state.timerRunning = true;
288
+ state.startTime = startTimestamp;
289
+ state.timeToStart = timeToStart;
290
+
291
+ sendDeltas([
292
+ {path: 'navigation.racing.startTime', value: startTimestamp},
293
+ {path: 'navigation.racing.timeToStart', value: timeToStart}
294
+ ]);
295
+
296
+ if (state.timerInterval) clearInterval(state.timerInterval);
297
+ state.timerInterval = setInterval(() => {
298
+ if (!state.timerRunning || state.timeToStart <= 0) {
299
+ clearInterval(state.timerInterval);
300
+ return;
301
+ }
302
+ state.timeToStart--;
303
+ sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]);
304
+ }, 1000);
305
+ cb(null, {state: 'COMPLETED'});
306
+ break;
307
+ }
308
+ case 'reset': {
309
+ state.timerRunning = false;
310
+ state.timeToStart = state.options.timer ?? 300;
311
+ if (state.timerInterval) clearInterval(state.timerInterval);
312
+
313
+ sendDeltas([
314
+ {path: 'navigation.racing.timeToStart', value: state.timeToStart},
315
+ {path: 'navigation.racing.startTime', value: null}
316
+ ]);
317
+ cb(null, {state: 'COMPLETED'});
318
+ break;
319
+ }
320
+
321
+ case 'sync': {
322
+ if (!state.timerRunning || state.timeToStart == null) {
323
+ cb(null, { state: 'FAILED', message: 'Timer not running' });
324
+ return;
325
+ }
326
+
327
+ const rounded = Math.round(state.timeToStart / 60) * 60;
328
+ state.timeToStart = rounded;
329
+ state.startTime = new Date(Date.now() + rounded * 1000).toISOString();
330
+
331
+ sendDeltas([
332
+ { path: 'navigation.racing.timeToStart', value: state.timeToStart },
333
+ { path: 'navigation.racing.startTime', value: state.startTime }
334
+ ]);
335
+ cb(null, { state: 'COMPLETED' });
336
+ break;
337
+ }
338
+
339
+ case 'set': {
340
+ const input = value.startTime;
341
+ if (!input) {
342
+ cb(null, { state: 'FAILED', message: 'Missing startTime' });
343
+ return;
344
+ }
345
+
346
+ const inputTime = new Date(input);
347
+ if (isNaN(inputTime)) {
348
+ cb(null, { state: 'FAILED', message: 'Invalid startTime format' });
349
+ return;
350
+ }
351
+
352
+ const now = new Date();
353
+ const diff = Math.floor((inputTime - now) / 1000);
354
+
355
+ if (diff <= 0) {
356
+ state.timerRunning = false;
357
+ state.timeToStart = 0;
358
+ state.startTime = null;
359
+ sendDeltas([
360
+ { path: 'navigation.racing.timeToStart', value: 0 },
361
+ { path: 'navigation.racing.startTime', value: null }
362
+ ]);
363
+ cb(null, { state: 'COMPLETED' });
364
+ return;
365
+ }
366
+
367
+ state.timerRunning = true;
368
+ state.timeToStart = diff;
369
+ state.startTime = inputTime.toISOString();
370
+
371
+ sendDeltas([
372
+ { path: 'navigation.racing.timeToStart', value: state.timeToStart },
373
+ { path: 'navigation.racing.startTime', value: state.startTime }
374
+ ]);
375
+
376
+ if (state.timerInterval) clearInterval(state.timerInterval);
377
+ state.timerInterval = setInterval(() => {
378
+ if (!state.timerRunning || state.timeToStart <= 0) {
379
+ clearInterval(state.timerInterval);
380
+ return;
381
+ }
382
+ state.timeToStart--;
383
+ sendDeltas([{ path: 'navigation.racing.timeToStart', value: state.timeToStart }]);
384
+ }, 1000);
385
+ cb(null, { state: 'COMPLETED' });
386
+ break;
387
+ }
388
+
389
+ case 'adjust': {
390
+ const delta = Number(value.delta);
391
+ if (isNaN(delta) || state.timeToStart == null || !state.startTime) {
392
+ cb(null, { state: 'FAILED', message: 'Cannot adjust: invalid state or delta' });
393
+ return;
394
+ }
395
+
396
+ state.timeToStart += delta;
397
+ if (state.timeToStart < 0) state.timeToStart = 0;
398
+
399
+ const adjustedStart = new Date(Date.now() + state.timeToStart * 1000).toISOString();
400
+ state.startTime = adjustedStart;
401
+
402
+ sendDeltas([
403
+ { path: 'navigation.racing.timeToStart', value: state.timeToStart },
404
+ { path: 'navigation.racing.startTime', value: adjustedStart }
405
+ ]);
406
+ cb(null, { state: 'COMPLETED' });
407
+ break;
408
+ }
409
+
410
+ default : {
411
+ app.error('Unknown command to start timer: ' + command);
412
+ cb(null, {state: 'FAILED'});
413
+ return;
414
+ }
260
415
  }
261
416
  } catch (err) {
262
- app.error('Failed to set start line end:', err);
263
- res.status(500).json({ error: 'Failed to set start line end' });
417
+ app.error('Failed to set start line end:', JSON.stringify(err));
418
+ cb(null, {state: 'FAILED'});
419
+ throw err;
264
420
  }
265
421
  }
266
422
 
@@ -474,6 +630,9 @@ module.exports = (app) => {
474
630
  state.wayPointsScanned = false;
475
631
  state.gpsFromBow = fromBow ? fromBow.value : null;
476
632
  state.gpsFromCenter = fromCenter ? fromCenter.value : null;
633
+ state.timeToStart = options.timer;
634
+
635
+ sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]);
477
636
 
478
637
  // Subscribe to position updates.
479
638
  app.subscriptionmanager.subscribe(
@@ -598,6 +757,9 @@ module.exports = (app) => {
598
757
  processRoute(route);
599
758
  }
600
759
  );
760
+
761
+ app.registerPutHandler('vessels.self', 'navigation.racing.setStartLine', setStartLine);
762
+ app.registerPutHandler('vessels.self', 'navigation.racing.setStartTime', startTimeCommand);
601
763
  },
602
764
 
603
765
  stop: () => {
@@ -607,13 +769,6 @@ module.exports = (app) => {
607
769
 
608
770
  schema: () => racerSchema,
609
771
 
610
- registerWithRouter: (router) => {
611
- router.get('/startline/port', (req, res) => res.json(state.startLine ? state.startLine.port : null));
612
- router.get('/startline/stb', (req, res) => res.json(state.startLine ? state.startLine.stb : null));
613
- router.put('/startline/:end', setStartLineEnd);
614
- router.put('/timeToStart', setTimeToStart);
615
- },
616
-
617
772
  getOpenApi: () => require('./openapi.json'),
618
773
  };
619
774
  };
Binary file
package/openapi.json CHANGED
@@ -1,39 +1,49 @@
1
1
  {
2
- "openapi": "3.0.0",
3
- "info": {
4
- "version": "0.0.1",
5
- "title": "Signal K Demo/Test Plugin OpenApi",
6
- "license": {
7
- "name": "Apache 2.0",
8
- "url": "http://www.apache.org/licenses/LICENSE-2.0.html"
9
- }
10
- },
11
2
  "paths": {
12
- "/startline/port": {
13
- "get": {
14
- "summary": "Set the port end of the start line",
15
- "responses": {
16
- "200": {
17
- "description": "Port end set"
18
- }
19
- }
20
- }
21
- },
22
- "/startline/stb": {
3
+ "/signalk/v1/api/vessels/self/navigation/racing/setStartLine": {
23
4
  "put": {
24
- "summary": "Set the starboard end of the start line",
25
- "description": "Set the starboard end of the start line to the boats position plus the bow offset.",
5
+ "summary": "Set or Adjust Start Line End",
6
+ "description": "Set or adjust the port/stb end of the line. Provide `end`, optionally `position`, `delta`, and/or `rotate`.",
7
+ "requestBody": {
8
+ "required": true,
9
+ "content": {
10
+ "application/json": {
11
+ "schema": {
12
+ "type": "object",
13
+ "properties": {
14
+ "end": { "type": "string", "enum": ["port", "stb"] },"position": {
15
+ "oneOf": [
16
+ { "type": "string", "enum": ["bow"] },
17
+ {
18
+ "type": "object",
19
+ "properties": {
20
+ "latitude": { "type": "number" },
21
+ "longitude": { "type": "number" }
22
+ },
23
+ "required": ["latitude", "longitude"]
24
+ }
25
+ ],
26
+ "description": "Either the string 'bow', or an object with latitude/longitude"
27
+ },
28
+ "delta": { "type": "number", "description": "Distance in meters to move the end along line bearing" },
29
+ "rotate": { "type": "number", "description": "Angle in radians to rotate end around the opposite end" }
30
+ },
31
+ "required": ["end"]
32
+ }
33
+ }
34
+ }
35
+ },
26
36
  "responses": {
27
37
  "200": {
28
- "description": "Starboard end set"
38
+ "description": "Start line modified"
29
39
  }
30
40
  }
31
41
  }
32
42
  },
33
- "/timeToStart": {
43
+ "/signalk/v1/api/vessels/self/navigation/racing/setStartTime": {
34
44
  "put": {
35
- "summary": "Set time to start",
36
- "description": "Update the countdown timer value used for start line calculations.",
45
+ "summary": "Set, Adjust or Command the Timer",
46
+ "description": "Set start time or send commands to the timer ('start', 'reset', 'sync', or 'adjust').",
37
47
  "requestBody": {
38
48
  "required": true,
39
49
  "content": {
@@ -41,43 +51,24 @@
41
51
  "schema": {
42
52
  "type": "object",
43
53
  "properties": {
44
- "timeToStart": {
45
- "type": "number",
46
- "description": "Time to start in seconds"
47
- }
54
+ "command": {
55
+ "type": "string",
56
+ "enum": ["start", "reset", "sync", "adjust", "set"]
57
+ },
58
+ "delta": { "type": "number", "description": "Time offset in seconds (for adjust)" },
59
+ "startTime": { "type": "string", "format": "date-time", "description": "ISO start time (for set)" }
48
60
  },
49
- "required": [
50
- "timeToStart"
51
- ]
61
+ "required": ["command"]
52
62
  }
53
63
  }
54
64
  }
55
65
  },
56
66
  "responses": {
57
67
  "200": {
58
- "description": "Time to start set",
59
- "content": {
60
- "application/json": {
61
- "schema": {
62
- "type": "object",
63
- "properties": {
64
- "message": { "type": "string" },
65
- "timeToStart": {
66
- "type": "number",
67
- "description": "Time to start in seconds"
68
- },
69
- "distanceToLine": {
70
- "type": "number",
71
- "description": "Distance to the line in meters"
72
- }
73
- },
74
- "required": ["message", "timeToStart"]
75
- }
76
- }
77
- }
68
+ "description": "Timer command executed"
78
69
  }
79
70
  }
80
71
  }
81
72
  }
82
73
  }
83
- }
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-racer",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Signalk plugin to calculate values of interest to sail racers: such as Distance to Line; Next leg TWA.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/public/index.html CHANGED
@@ -2,8 +2,18 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <title>SignalK Racer</title>
5
+ <title>SignalK Racer Control</title>
6
6
  <style>
7
+ .button-timer-command { background-color: #041b8e; color: white; }
8
+ .button-timer-adjust { background-color: #2196F3; color: white; }
9
+ button {
10
+ padding: 1em 1.5em;
11
+ font-size: 1em;
12
+ cursor: pointer;
13
+ border: none;
14
+ border-radius: 4px;
15
+ }
16
+
7
17
  body {
8
18
  font-family: sans-serif;
9
19
  max-width: 100%;
@@ -11,43 +21,301 @@
11
21
  text-align: center;
12
22
  }
13
23
  button {
14
- padding: 1em 2em;
15
- margin: 1em;
16
- font-size: 1.2em;
24
+ padding: 1em 1.5em;
25
+ font-size: 1em;
17
26
  cursor: pointer;
18
27
  }
28
+ input[readonly] {
29
+ background: #eee;
30
+ border: 1px solid #ccc;
31
+ padding: 0.3em;
32
+ }
19
33
  #status {
20
34
  margin-top: 2em;
21
35
  font-size: 1em;
22
36
  color: green;
23
37
  }
38
+
39
+ .port-set { background-color: #f88; color: black; }
40
+ .port-move { background-color: #a00; color: white; }
41
+ .port-tweak { background-color: #f44; color: white; }
42
+ .port-rotate { background-color: #a00; color: white; }
43
+ .stb-set { background-color: #8f8; color: black; }
44
+ .stb-move { background-color: #080; color: white; }
45
+ .stb-tweak { background-color: #4c4; color: white; }
46
+ .stb-rotate { background-color: #080; color: white; }
47
+
24
48
  </style>
25
49
  </head>
26
50
  <body>
27
- <h1>Set Start Line Ends</h1>
28
- <div id="buttons">
29
- <button onclick="setEnd('port')">Set Port End (Pin)</button><button onclick="setEnd('stb')">Set Starboard End (Boat)</button>
30
- </div>
31
- <div id="status"></div>
32
51
 
33
52
  <script>
34
- async function setEnd(end) {
35
- const url = `/plugins/signalk-racer/startline/${end}`;
36
- document.getElementById('status').textContent = `Sending request to ${url}...`;
37
-
38
- try {
39
- const res = await fetch(url, { method: 'PUT' });
40
- if (res.ok) {
41
- const data = await res.json();
42
- document.getElementById('status').textContent = `✅ ${end.toUpperCase()} set at bow position.`;
43
- } else {
44
- const err = await res.text();
45
- throw new Error(`Error ${res.status}: ${err}`);
53
+ let socket;
54
+ let statusTimer;
55
+ let startTime;
56
+ function updateStatus(message) {
57
+ const status = document.getElementById('status');
58
+ status.textContent = message;
59
+ if (statusTimer) clearTimeout(statusTimer);
60
+ statusTimer = setTimeout(() => {
61
+ if (status.textContent === message) {
62
+ status.textContent = '-';
63
+ }
64
+ }, 10000);
65
+ }
66
+
67
+ function toRadians(degrees) {
68
+ return degrees ? degrees * (Math.PI / 180) : null;
69
+ }
70
+
71
+ function connectWebSocket() {
72
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
73
+ const socketUrl = protocol + '//' + window.location.host + '/signalk/v1/stream?subscribe=none';
74
+ socket = new WebSocket(socketUrl);
75
+
76
+ socket.onopen = () => {
77
+ updateStatus('✅ Connected to Signal K server');
78
+ socket.send(JSON.stringify({
79
+ context: "vessels.self",
80
+ subscribe: [
81
+ {
82
+ path: "navigation.racing.*",
83
+ policy: "instant"
84
+ }
85
+ ]
86
+ }));
87
+ };
88
+
89
+ socket.onerror = (err) => updateStatus('❌ WebSocket error: ' + err.message);
90
+ socket.onclose = () => {
91
+ updateStatus('❌ Disconnected. Retrying in 5s...');
92
+ setTimeout(connectWebSocket, 5000);
93
+ };
94
+
95
+ socket.onmessage = (event) => {
96
+ const data = JSON.parse(event.data);
97
+ if (data.updates) {
98
+ data.updates.forEach(update => {
99
+ if (update.values) {
100
+ update.values.forEach(value => {
101
+ if (value.path === "navigation.racing.startLineLength") {
102
+ document.getElementById("lineLength").value = value.value.toFixed(1);
103
+ } else if (value.path === "navigation.racing.stbLineBias") {
104
+ document.getElementById("lineBias").value = value.value.toFixed(1);
105
+ } else if (value.path === "navigation.racing.timeToStart") {
106
+ document.getElementById('timeToStart').textContent = formatTimeToMMSS(value.value);
107
+ } else if (value.path === "navigation.racing.startTime") {
108
+ if (!value.value) {
109
+ document.getElementById('startTime').value = "HH:MM:SS";
110
+ } else {
111
+ startTime = new Date(value.value); // handles ISO 8601 format
112
+ document.getElementById('startTime').value =
113
+ startTime.toLocaleTimeString([], {
114
+ hour: '2-digit',
115
+ minute: '2-digit',
116
+ second: '2-digit'
117
+ });
118
+ }
119
+ } else {
120
+ console.log("Received value: " + value.path + " = " + JSON.stringify(value.value));
121
+ }
122
+ });
123
+ }
124
+ });
46
125
  }
47
- } catch (e) {
48
- document.getElementById('status').textContent = `❌ Failed: ${e.message}`;
126
+ };
127
+ }
128
+
129
+ function sendSetStartLine(end) {
130
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
131
+ updateStatus('❌ WebSocket not connected.');
132
+ return;
49
133
  }
134
+ let message = JSON.stringify({
135
+ context: 'vessels.self',
136
+ put: {
137
+ path: 'navigation.racing.setStartLine',
138
+ value: { end, position : 'bow'}
139
+ }
140
+ });
141
+ console.log("sendSetStartLine: " + message);
142
+ socket.send(message);
143
+ updateStatus(`⏳ Sent request to set ${end}`);
50
144
  }
145
+
146
+ function sendAdjustStartLine(end, delta, rotate) {
147
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
148
+ updateStatus('❌ WebSocket not connected.');
149
+ return;
150
+ }
151
+ const message = JSON.stringify({
152
+ context: 'vessels.self',
153
+ put: {
154
+ path: 'navigation.racing.setStartLine',
155
+ value: { end, delta, rotate : rotate ? toRadians(rotate) : null }
156
+ }
157
+ });
158
+ console.log("sendAdjustStartLine: " + message);
159
+ socket.send(message);
160
+ updateStatus(`⏳ Sent ${delta}m ${rotate}° request for ${end}`);
161
+ }
162
+
163
+ connectWebSocket();
164
+
165
+ function formatTimeToMMSS(seconds) {
166
+ const mm = Math.floor(seconds / 60);
167
+ const ss = Math.floor(seconds % 60);
168
+ return `${mm.toString().padStart(2, '0')}:${ss.toString().padStart(2, '0')}`;
169
+ }
170
+
171
+ function adjustStartTime(deltaSeconds) {
172
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
173
+ updateStatus('❌ WebSocket not connected.');
174
+ return;
175
+ }
176
+ socket.send(JSON.stringify({
177
+ context: 'vessels.self',
178
+ put: {
179
+ path: 'navigation.racing.setStartTime',
180
+ value: {
181
+ command: 'adjust',
182
+ delta: deltaSeconds
183
+ }
184
+ }
185
+ }));
186
+ updateStatus(`⏳ Sent delta timeToStart: ${deltaSeconds} sec`);
187
+ }
188
+
189
+ function setStartTime() {
190
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
191
+ updateStatus('❌ WebSocket not connected.');
192
+ return;
193
+ }
194
+
195
+ const date = startTime ? new Date(startTime) : new Date();
196
+ const [hours, minutes, seconds] = document.getElementById('startTime').value.split(':').map(Number);
197
+ date.setHours(hours, minutes, seconds, 0);
198
+
199
+ socket.send(JSON.stringify({
200
+ context: 'vessels.self',
201
+ put: {
202
+ path: 'navigation.racing.setStartTime',
203
+ value: {
204
+ command: 'set',
205
+ startTime : date.toISOString(),
206
+ }
207
+ }
208
+ }));
209
+ updateStatus(`⏳ Sent start time: ${startTime}`);
210
+ }
211
+
212
+ function sendTimerCommand(command) {
213
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
214
+ updateStatus('❌ WebSocket not connected.');
215
+ return;
216
+ }
217
+ socket.send(JSON.stringify({
218
+ context: 'vessels.self',
219
+ put: {
220
+ path: 'navigation.racing.setStartTime',
221
+ value: { command }
222
+ }
223
+ }));
224
+ updateStatus(`⏳ Sent timer command: ${command}`);
225
+ }
226
+
227
+ setInterval(() => {
228
+ if (timeToStart !== null && timeToStart > 0) {
229
+ timeToStart--;
230
+ updateTimerDisplay();
231
+ }
232
+ }, 1000);
233
+
51
234
  </script>
235
+
236
+ <h1>Adjust Start Line</h1>
237
+ <div style="display: flex; justify-content: center; align-items: center; gap: 3em; flex-wrap: wrap;">
238
+
239
+
240
+ <div style="display: grid; grid-template-columns: repeat(11, auto); grid-template-rows: repeat(3, auto); gap: 0.5em;">
241
+ <div></div>
242
+ <div></div>
243
+ <button class="port-rotate" onclick="sendAdjustStartLine('port', 0, 1)">+1°</button>
244
+ <div></div>
245
+ <div></div>
246
+
247
+ <div></div>
248
+
249
+ <div></div>
250
+ <div></div>
251
+ <button class="stb-rotate" onclick="sendAdjustStartLine('stb', 0, -1)">+1°</button>
252
+ <div></div>
253
+ <div></div>
254
+
255
+ <!--------->
256
+
257
+ <button class="port-move" onclick="sendAdjustStartLine('port', 10, 0)">+10m</button>
258
+ <button class="port-tweak" onclick="sendAdjustStartLine('port', 1, 0)">+1m</button>
259
+ <button class="port-set" onclick="sendSetStartLine('port')">Port<br/>(pin)</button>
260
+ <button class="port-tweak" onclick="sendAdjustStartLine('port', -1, 0)">-1m</button>
261
+ <button class="port-move" onclick="sendAdjustStartLine('port', -10, 0)">-10m</button>
262
+
263
+ <div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
264
+ ---------------<br/>
265
+ <label><input id="lineLength" readonly style="width: 5em; text-align: center;">(m)</label>
266
+ </div>
267
+
268
+ <button class="stb-move" onclick="sendAdjustStartLine('stb', -10, 0)">-10m</button>
269
+ <button class="stb-tweak" onclick="sendAdjustStartLine('stb', -1, 0)">-1m</button>
270
+ <button class="stb-set" onclick="sendSetStartLine('stb')">Starboard<br/>(boat)</button>
271
+ <button class="stb-tweak" onclick="sendAdjustStartLine('stb', 1, 0)">+1m</button>
272
+ <button class="stb-move" onclick="sendAdjustStartLine('stb', +10, 0)">+10m</button>
273
+
274
+ <!--------->
275
+
276
+ <div></div>
277
+ <div></div>
278
+ <button class="port-rotate" onclick="sendAdjustStartLine('port', 0, -1)">-1°</button>
279
+ <div></div>
280
+ <div></div>
281
+
282
+ <div style="display: flex; flex-direction: column; align-items: center; justify-content: center;">
283
+ <label>Bias: <input id="lineBias" readonly style="width: 5em; text-align: center;">(m)</label>
284
+ </div>
285
+
286
+ <div></div>
287
+ <div></div>
288
+ <button class="stb-rotate" onclick="sendAdjustStartLine('stb', 0, 1)">-1°</button>
289
+ <div></div>
290
+ <div></div>
291
+ </div>
292
+
293
+
294
+ </div>
295
+
296
+ <div id="status">Connecting...</div>
297
+
298
+ <!-- Timer Display -->
299
+ <div style="margin-top: 2em;">
300
+ <div id="timeToStart" style="font-size: 3em; font-weight: bold;">00:00</div>
301
+ </div>
302
+
303
+ <!-- Start Time Row -->
304
+ <div style="margin-top: 1em; display: flex; justify-content: center; align-items: center; gap: 1em;">
305
+ <button class="button-timer-adjust" onclick="adjustStartTime(-60)">-1 min</button>
306
+ <button class="button-timer-adjust" onclick="adjustStartTime(-1)">-1s</button>
307
+ <label for="startTime">Start at: <input id="startTime" style="font-size: 1.5em; width: 10em; text-align: center;"></label>
308
+ <button class="button-timer-adjust" onclick="adjustStartTime(1)">+1s</button>
309
+ <button class="button-timer-adjust" onclick="adjustStartTime(60)">+1 min</button>
310
+ </div>
311
+
312
+ <!-- Timer Control Buttons -->
313
+ <div style="margin-top: 1em;">
314
+ <button class="button-timer-command" onclick="sendTimerCommand('start')">Start</button>
315
+ <button class="button-timer-command" onclick="setStartTime()">Start At</button>
316
+ <button class="button-timer-command" onclick="sendTimerCommand('sync')">Sync</button>
317
+ <button class="button-timer-command" onclick="sendTimerCommand('reset')">Reset</button>
318
+ </div>
319
+
52
320
  </body>
53
321
  </html>