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 +55 -33
- package/ReleaseNotes.md +6 -1
- package/index.js +254 -99
- package/kip-freeboard-screenshot.png +0 -0
- package/openapi.json +45 -54
- package/package.json +1 -1
- package/public/index.html +291 -23
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Signal K Racing Plugin
|
|
2
2
|
|
|
3
|
-
This
|
|
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
|
|
14
|
-
|
|
15
|
-
| `navigation.racing.`<br/>`distanceStartline` | Signed minimum distance from the bow to the start line | `m`
|
|
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`
|
|
19
|
-
| `navigation.racing.`<br/>`startLinePort.longitude` | Longitude of the port (pin) end of the start line | `deg`
|
|
20
|
-
| `navigation.racing.`<br/>`startLineStb.latitude` | Latitude of the starboard (boat) end of the start line | `deg`
|
|
21
|
-
| `navigation.racing.`<br/>`startLineStb.longitude` | Longitude of the starboard (boat) end of the start line | `deg`
|
|
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
|
|
39
|
-
|
|
40
|
-
| `startLineStb`
|
|
41
|
-
| `startLinePort`
|
|
42
|
-
| `period`
|
|
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
|
-
## 🌐
|
|
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
|
-
|
|
93
|
+
Used to **set** or **adjust** either end of the start line.
|
|
89
94
|
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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: '
|
|
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
|
-
|
|
25
|
-
speedToLine: {
|
|
23
|
+
timer: {
|
|
26
24
|
type: 'number',
|
|
27
|
-
title: '
|
|
28
|
-
default:
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
153
|
+
async function setStartLine(context, path, value, cb) {
|
|
189
154
|
try {
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
162
|
+
app.error('Failed to set start line: unknown end');
|
|
163
|
+
cb(null, {state: 'FAILED'});
|
|
194
164
|
}
|
|
195
165
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
173
|
+
|
|
174
|
+
app.debug(`setStartLine[${end}] = ${JSON.stringify(position)} before translation/rotation`);
|
|
204
175
|
const startLine = state.startLine;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
type: '
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
+
app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`);
|
|
234
233
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
if (waypointId)
|
|
235
|
+
await app.resourcesApi.setResource('waypoints', waypointId, waypoint);
|
|
236
|
+
else
|
|
237
|
+
await app.resourcesApi.createResource('waypoints', waypoint);
|
|
238
|
+
}
|
|
239
239
|
|
|
240
|
-
|
|
241
|
-
|
|
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] =
|
|
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
|
-
{
|
|
254
|
-
{
|
|
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(
|
|
259
|
+
processPosition(null);
|
|
258
260
|
} else {
|
|
259
|
-
findLineAndThenProcess(
|
|
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
|
-
|
|
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
|
-
"/
|
|
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
|
|
25
|
-
"description": "Set the
|
|
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": "
|
|
38
|
+
"description": "Start line modified"
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
},
|
|
33
|
-
"/
|
|
43
|
+
"/signalk/v1/api/vessels/self/navigation/racing/setStartTime": {
|
|
34
44
|
"put": {
|
|
35
|
-
"summary": "Set
|
|
36
|
-
"description": "
|
|
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
|
-
"
|
|
45
|
-
"type": "
|
|
46
|
-
"
|
|
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": "
|
|
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
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
|
|
15
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
}
|
|
48
|
-
|
|
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>
|