signalk-racer
Version:
Signalk plugin to calculate values of interest to sail racers, such as: Time to Start; Time of Start, Time to Burn; Distance to Line; Next leg TWA.
1,153 lines (1,048 loc) • 52.3 kB
JavaScript
module.exports = (app) => {
const state = {
startLineName: null,
startLine: null,
};
const geolib = require('geolib')
const racerSchema = {
title: 'Signalk Racer Configuration',
type: 'object',
properties: {
startLineStb: {
type: 'string',
title: 'Start line starboard-end (boat) waypoint name',
default: 'startBoat'
},
startLinePort: {
type: 'string',
title: 'Start line port-end (pin) waypoint name',
default: 'startPin'
},
updateStartLineWaypoint: {
type: 'boolean',
title: 'Update the start line waypoints if the startLine is updated',
default: true
},
createStartLineWaypoint: {
type: 'boolean',
title: 'Create the start line waypoints if the startLine is updated and the waypoint does not exist',
default: true
},
period: {
type: 'number',
title: 'The subscription period in microseconds used for position and wind deltas',
default: 1000
},
timer: {
type: 'number',
title: 'Start Timer default initial period in seconds',
default: 300
},
maxSamples : {
type: 'number',
title: 'Number of samples to collect for VMG calculations',
default: 600
},
percentile: {
type: 'number',
title: 'Percentile used to select sample for VMG calculations (0-1)',
default: 0.9
},
minSog: {
type: 'number',
title: 'Minimum speed over ground (SOG) in knots to consider for VMG calculations',
default: 1.0
},
maxDistance: {
type: 'number',
title: 'Maximum distance to line and/or line zone in meters to consider for VMG calculations',
default: 2000
},
lines: {
type: 'array',
title: 'Named lines',
description: 'Add zero or more named start lines',
items: {
type: 'object',
title: 'Named line',
properties: {
startLineName: {
type: 'string',
title: 'Line name',
},
startLineStb: {
type: 'string',
title: 'Start line starboard-end (boat) waypoint name'
},
startLinePort: {
type: 'string',
title: 'Start line port-end (pin) waypoint name'
},
updateStartLineWaypoint: {
type: 'boolean',
title: 'Update the start line waypoints if the startLine is updated',
default: true
},
createStartLineWaypoint: {
type: 'boolean',
title: 'Create the start line waypoints if the startLine is updated and the waypoint does not exist',
default: true
},
startLineDescription: {
type: 'string',
title: 'Optional description'
}
},
required: ['startLineName', 'startLineStb', 'startLinePort']
}
}
}
}
const { v4: uuidv4 } = require('uuid');
const {
initRacer,
toDegrees,
toRadians,
collectVmgSamples,
resetVmgSamples,
computeTimeToLine
} = require('./racer');
const unsubscribes = [];
// Update meta data for non standard paths
app.handleMessage('vessels.self', {
context: "vessels.self",
updates: [
{
timestamp: new Date().toISOString(),
"meta": [
{
"path": "navigation.racing.startLineLength",
"value": {
"type": "number",
"units": "m",
"description": "Length of the start line",
"displayName": "Length of the start line",
"shortName": "SLL"
}
},
{
"path": "navigation.racing.stbLineBias",
"value": {
"type": "number",
"units": "m",
"description": "Bias of the start line for the starboard end",
"displayName": "Bias of the start line for the starboard end",
"shortName": "Bias"
}
},
{
"path": "navigation.racing.nextLegHeading",
"value": {
"type": "number",
"units": "rad",
"description": "Heading of the next leg of the course",
"displayName": "Next Heading",
"shortName": "NextHDG",
}
},
{
"path": "navigation.racing.nextLegTrueWindAngle",
"value": {
"type": "number",
"units": "rad",
"description": "TWA on the next leg of the course",
"displayName": "Next TWA",
"shortName": "NextTWA",
}
},
{
"path": "navigation.racing.startTime",
"value": {
"type": "string",
"units": "RFC 3339 (UTC)",
"example": "2014-04-10T08:33:53.123Z",
"format": "date-time",
"pattern" : ".*Z$",
"description": "Time of the race start in RFC 3339 UTC only format ",
"displayName": "Start Time",
"shortName": "StartTime",
}
},
{
"path": "navigation.racing.timeToLine",
"value": {
"type": "number",
"units": "s",
"description": "Time to sail to the the line at best VMG",
"displayName": "Time to line",
"shortName": "TTS"
}
},
{
"path": "navigation.racing.timeToBurn",
"value": {
"type": "number",
"units": "s",
"description": "Time to delay before sailing to the the start line",
"displayName": "Time to burn",
"shortName": "TTB"
}
}
]
}
]
});
// send multiple deltas, each in the form of { path, value }
// null values are preserved (meaningful in SignalK for clearing a value);
// undefined values are dropped to avoid "Delta is missing value" warnings.
function sendDeltas(updatesArray) {
const values = updatesArray
.filter(({value}) => value !== undefined)
.map(({path, value}) => ({path, value}));
if (values.length === 0) return;
const delta = {
context: 'vessels.self',
updates: [
{
source: {label: 'signalk-racer'},
timestamp: new Date().toISOString(),
values
}
]
};
app.debug('Sending deltas:', JSON.stringify(delta));
app.handleMessage('vessels.self', delta);
}
function waypointToPosition(waypoint) {
if (
waypoint &&
waypoint.feature &&
waypoint.feature.geometry &&
waypoint.feature.geometry.type === 'Point' &&
Array.isArray(waypoint.feature.geometry.coordinates) &&
waypoint.feature.geometry.coordinates.length === 2
) {
const [longitude, latitude] = waypoint.feature.geometry.coordinates;
return {latitude, longitude};
} else {
return null;
}
}
function toOtherEnd(end) {
switch (end) {
case 'port' :
return 'stb';
case 'stb' :
return 'port';
default:
return null;
}
}
function bowPosition(position) {
let headingTrue = app.getSelfPath("navigation.headingTrue");
if (!headingTrue)
headingTrue = app.getSelfPath("navigation.courseOverGroundTrue");
if (!headingTrue || (!state.gpsFromBow && !state.gpsFromCenter))
return position
headingTrue = toDegrees(headingTrue.value);
let bow = position;
if (state.gpsFromBow)
bow = geolib.computeDestinationPoint(bow, state.gpsFromBow, headingTrue);
if (state.gpsFromCenter)
bow = geolib.computeDestinationPoint(bow, state.gpsFromCenter, headingTrue + 90);
return bow;
}
function camelCase(name) {
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
function complete(callback, statusCode, message, details) {
const result = {state: statusCode === 200 ? 'SUCCESS' : 'FAILURE', statusCode, message, details};
app.debug(JSON.stringify(result));
return result;
}
function publishLineList() {
const lines = Array.isArray(state.options.lines) ? state.options.lines : [];
const payload = {
startLineName: state.startLineName ?? null,
lines: lines.map(l => ({
startLineName: l.startLineName,
startLineDescription: l.startLineDescription || null
}))
};
app.handleMessage('vessels.self', {
context: "vessels.self",
updates: [
{
values: [
{
path: 'navigation.racing.lines',
value: payload
}
]
}
]
});
}
// Put an absolute position
async function putStartLineEnd(end, position, callback, updateWaypoint) {
app.debug(`putStartLineEnd: ${end} ${JSON.stringify(position)}`);
try {
if (!end || !position || typeof position.latitude !== 'number' || typeof position.longitude !== 'number')
return complete(callback, 400, 'Failed to put start line end: !end || !position');
end = end.toLowerCase();
if (end !== 'port' && end !== 'stb')
return complete(callback, 400, 'Failed to put start line end: unknown end');
let startLine = state.startLine;
if (startLine) {
// We have a line, check if this end is the same?
if (startLine[end].latitude === position.latitude && startLine[end].longitude === position.longitude) {
return complete(callback, 304, 'Put start line end: unchanged line end');
}
app.debug('update existing start line');
let newStartline = {...startLine};
newStartline[end] = position;
startLine = newStartline;
} else {
app.debug('No startLine');
// We don't have a line, so check if this is just an update for one end of the line?
const thisEnd = app.getSelfPath(`navigation.racing.startLine${camelCase(end)}`);
app.debug('thisEnd ', JSON.stringify(thisEnd));
if (thisEnd && thisEnd.value && thisEnd.value.latitude === position.latitude && thisEnd.value.longitude === position.longitude) {
return complete(callback, 304, 'Put start line end: unchanged end');
}
// If we now have both ends, we have a line!
const otherEnd = toOtherEnd(end);
app.debug('otherEnd ', otherEnd);
const otherEndPosition = app.getSelfPath(`navigation.racing.startLine${camelCase(otherEnd)}`);
app.debug('otherEndPosition ', otherEndPosition);
if (otherEndPosition && otherEndPosition.value) {
startLine = {};
startLine[end] = position;
startLine[otherEnd] = otherEndPosition.value;
app.debug('startLine ', JSON.stringify(startLine));
}
}
// If we have a startLine, we can send deltas for this end and the length
if (startLine) {
app.debug('send start line');
startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1);
startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port);
state.startLine = startLine;
sendDeltas([
{path: `navigation.racing.startLine${camelCase(end)}`, value: position},
{path: 'navigation.racing.startLineLength', value: startLine.length},
]);
} else {
// otherwise we can only send the delta for this end
sendDeltas([
{path: `navigation.racing.startLine${camelCase(end)}`, value: position},
]);
}
// Set/create the waypoint if we are configured to do so
const startLineOptions = getStartLineOptions();
if (startLineOptions.updateStartLineWaypoint || updateWaypoint) {
try {
const waypointConfig = `startLine${camelCase(end)}`;
app.debug(`waypointConfig: ${waypointConfig}`);
const waypointName = getStartLineOptions()[waypointConfig];
app.debug(`waypointName: ${waypointName}`);
let waypointId = startLine ? startLine[end + 'Id'] : null;
app.debug(`waypointId: ${waypointId}`);
// If we have not cached the Id of the line end, then
if (!waypointId) {
// Get the ID of the last existing waypoint with the given name.
const waypoints = await app.resourcesApi.listResources('waypoints', {});
for (const [id, resource] of Object.entries(waypoints)) {
if (resource.name === waypointName) {
waypointId = id;
// don't break here as we want the last waypoint with the given name
}
}
app.debug(`waypointId: ${waypointId}`);
}
app.debug(`Startline waypoint to set ${end}(${waypointName}/${waypointId}): ${JSON.stringify(position)}`);
const waypoint = {
name: waypointName,
feature: {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [position.longitude, position.latitude]
},
properties: {}
},
type: end === 'port' ? 'start-pin' : 'start-boat',
position: {
latitude: position.latitude,
longitude: position.longitude
}
}
app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`);
if (!waypointId && startLineOptions.createStartLineWaypoint)
waypointId = uuidv4();
if (waypointId)
await app.resourcesApi.setResource('waypoints', waypointId, waypoint);
else
app.debug('startline waypoint not created');
}
catch (err) {
app.error(`Failed to set startline waypoint: ${err}`);
}
} else {
app.debug('startline waypoint not updated');
}
// If we have a startLine, then process position against the new line
if (startLine)
processPosition(null);
// Complete the handler successfully
return complete(callback, 200, 'Put start line end: OK');
} catch (err) {
return complete(callback, 500, 'Put start line end: Failed ' + err);
}
}
async function setStartLineName(context, path, args, callback) {
try {
app.debug('setStartLineName:', JSON.stringify(args));
if (!args || args.startLineName != null && typeof args.startLineName !== 'string') {
return complete(callback, 400, 'Failed to set the startLine name!');
}
if (state.startLineName !== args.startLineName) {
state.startLineName = args.startLineName;
state.startLine = null;
findLineAndThenProcess(null, true);
}
publishLineList();
return complete(callback, 200, 'Put start line name: OK');
} catch (err) {
return complete(callback, 500, 'Put start line name: Failed ' + err);
}
}
async function setStartLine(context, path, args, callback) {
try {
app.debug('setStartLine:', JSON.stringify(args));
if (!args || !args.end || typeof args.end !== 'string') {
return complete(callback, 400, 'Failed to set the startLine: !end');
}
const end = args.end.toLowerCase();
if (end !== 'port' && end !== 'stb') {
return complete(callback, 400, 'Failed to set the startLine: unknown end');
}
// Get the position either as arg or as the current bow position
let position = args.position;
if (position === 'bow' || !position && !args.delta && !args.rotate ) {
position = app.getSelfPath('navigation.position');
app.debug('bowPosition:' + JSON.stringify(position));
if (position)
position = bowPosition(position.value);
}
app.debug(`setStartLine[${end}] = ${JSON.stringify(position)} before translation/rotation`);
const startLine = state.startLine ? state.startLine : getStartLine();
// Do any rotations or length changes
if (startLine && (args.delta || args.rotate)) {
app.debug(`setStartLine[${end}] translate ${args.delta} and rotate ${args.rotate} of ${JSON.stringify(startLine)}`);
const otherEnd = startLine[toOtherEnd(end)];
const thisEnd = position ? position : startLine[end];
const initialBearing = geolib.getRhumbLineBearing(otherEnd, thisEnd);
const currentDistance = geolib.getDistance(otherEnd, thisEnd);
const delta = (args.delta && typeof args.delta === 'number' && end === 'stb') ? -args.delta : args.delta;
const newDistance = delta && typeof delta === 'number' ? (currentDistance + args.delta) : currentDistance;
const newBearing = args.rotate && typeof args.rotate === 'number' ? (initialBearing + toDegrees(args.rotate)) : initialBearing;
const newPosition = geolib.computeDestinationPoint(otherEnd, newDistance, newBearing);
app.debug(`Moved/Rotated ${end} from ${JSON.stringify(position)} to ${JSON.stringify(newPosition)}`);
position = newPosition;
}
if (position)
return putStartLineEnd(end, position, callback, getStartLineOptions().updateStartLineWaypoint);
return complete(callback, 500, 'Failed to set the startLine: No position');
} catch (err) {
return complete(callback, 500, 'Failed to set the startLine: ' + err);
}
}
function setTimer() {
if (state.timerInterval)
clearInterval(state.timerInterval);
state.timerInterval = setInterval(() => {
if (!state.timerRunning || state.timeToStart <= 0) {
clearInterval(state.timerInterval);
return;
}
state.timeToStart--;
sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]);
}, 1000);
}
async function startTimeCommand(context, path, args, callback) {
// start / reset / sync
try {
app.debug('startTimerCommand:', JSON.stringify(args));
if (!args || !args.command || typeof args.command !== 'string') {
return complete(callback, 400, 'Failed to command timer: no command');
}
const command = args.command.toLowerCase();
switch (command) {
case 'start': {
const now = new Date();
const timeToStart = state.timeToStart ?? state.options.timer ?? 300;
const startTimestamp = new Date(now.getTime() + timeToStart * 1000).toISOString();
state.timerRunning = true;
state.startTime = startTimestamp;
state.timeToStart = timeToStart;
setTimer();
sendDeltas([
{path: 'navigation.racing.startTime', value: startTimestamp},
{path: 'navigation.racing.timeToStart', value: timeToStart}
]);
return complete(callback, 200, 'start timer: OK');
}
case 'reset': {
state.timerRunning = false;
state.timeToStart = state.options.timer ?? 300;
if (state.timerInterval) clearInterval(state.timerInterval);
sendDeltas([
{path: 'navigation.racing.timeToStart', value: state.timeToStart},
{path: 'navigation.racing.startTime', value: null}
]);
return complete(callback, 200, 'reset timer: OK');
}
case 'sync': {
if (!state.timerRunning || state.timeToStart == null)
return complete(callback, 500, 'sync timer: !running');
const rounded = Math.round(state.timeToStart / 60) * 60;
state.timeToStart = rounded;
state.startTime = new Date(Date.now() + rounded * 1000).toISOString();
setTimer();
sendDeltas([
{path: 'navigation.racing.timeToStart', value: state.timeToStart},
{path: 'navigation.racing.startTime', value: state.startTime}
]);
return complete(callback, 200, 'sync timer: OK');
}
case 'set': {
const input = args.startTime;
if (!input) {
return complete(callback, 400, 'reset timer: !startTime');
}
const inputTime = new Date(input);
if (isNaN(inputTime)) {
return complete(callback, 400, 'reset timer: invalid startTime');
}
const now = new Date();
const secondsToGo = Math.floor((inputTime - now) / 1000);
if (secondsToGo <= 0) {
state.timerRunning = false;
state.timeToStart = 0;
state.startTime = null;
sendDeltas([
{path: 'navigation.racing.timeToStart', value: 0},
{path: 'navigation.racing.startTime', value: null}
]);
return complete(callback, 200, 'set timer: Ignored negative start time');
}
state.timerRunning = true;
state.timeToStart = secondsToGo;
state.startTime = inputTime.toISOString();
setTimer();
sendDeltas([
{path: 'navigation.racing.timeToStart', value: state.timeToStart},
{path: 'navigation.racing.startTime', value: state.startTime}
]);
return complete(callback, 200, 'set timer: OK');
}
case 'adjust': {
const delta = Number(args.delta);
if (isNaN(delta))
return complete(callback, 400, 'adjust timer: Cannot adjust invalid delta');
if (state.timeToStart == null)
return complete(callback, 400, 'adjust timer: Cannot adjust start time');
const nextTimeToStart = state.timeToStart + delta;
if (nextTimeToStart <= 0)
return complete(callback, 200, 'adjust timer: Ignored negative start time');
state.timeToStart = nextTimeToStart;
if (state.timerRunning) {
let now = Date.now();
state.startTime = new Date(now - (now % 1000) + state.timeToStart * 1000).toISOString();
}
const deltas = [
{ path: 'navigation.racing.timeToStart', value: state.timeToStart }
];
if (state.timerRunning && state.startTime) {
deltas.push({path: 'navigation.racing.startTime', value: state.startTime });
}
if (delta % 60 !== 0)
setTimer();
sendDeltas(deltas);
return complete(callback, 200, 'adjust timer: OK');
}
default : {
return complete(callback, 400, 'Failed to command timer: unknown command');
}
}
} catch (err) {
app.debug('startTimerCommand:', err);
return complete(callback, 500, 'Failed to command timer: ' + err.message);
}
}
function findLineAndThenProcess(position, alwaysFindLine = false) {
if (!state.startLine || alwaysFindLine) {
app.resourcesApi.listResources(
'waypoints',
{}
).then(data => {
state.wayPointsScanned = true;
const startLine = {
stb: null,
port: null,
length: null,
bearing: null,
}
const startLineOptions = getStartLineOptions();
app.debug(`Start Line Options [${state.startLineName}]:`, JSON.stringify(startLineOptions));
for (const [key, value] of Object.entries(data)) {
app.debug(`WAYPOINT: ${key}, Value:`, JSON.stringify(value));
if (value.name === startLineOptions.startLinePort) {
let pos = waypointToPosition(value);
if (pos) {
startLine.portId = key;
startLine.port = pos;
}
}
if (value.name === startLineOptions.startLineStb) {
let pos = waypointToPosition(value);
if (pos) {
startLine.stbId = key;
startLine.stb = pos;
}
}
}
let deltas;
if (startLine.port && startLine.stb) {
startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1);
startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port);
app.debug(`STARTLINE: startLinePort: ${JSON.stringify(startLine)}`);
state.startLine = startLine;
resetVmgSamples();
deltas = [
{path: 'navigation.racing.startLinePort', value: startLine.port},
{path: 'navigation.racing.startLineStb', value: startLine.stb},
{path: 'navigation.racing.startLineLength', value: startLine.length},
].filter(entry => entry.value !== null && entry.value !== undefined);
} else {
app.debug(`STARTLINE: undefined`);
state.startLine = null;
deltas = [
{path: 'navigation.racing.startLinePort', value: null},
{path: 'navigation.racing.startLineStb', value: null},
{path: 'navigation.racing.startLineLength', value: null},
];
}
if (deltas.length > 0) {
sendDeltas(deltas);
processPosition(position);
processWind();
}
}).catch(err => {
app.error(err);
});
} else {
processPosition(position);
}
}
function isNearLineInRoute() {
// e.g. {
// "href":"/resources/routes/a12eefe7-8fd3-49f7-81af-ce1f3440d3ff",
// "name":"Course 1",
// "reverse":false,
// "pointIndex":1,
// "pointTotal":5
// }
const activeRoute = state.activeRoute;
if (!activeRoute)
return true;
if (activeRoute.pointIndex < 2)
return true;
return activeRoute.pointIndex + 2 >= activeRoute.pointTotal;
}
function processPosition(position) {
if (!position) {
position = app.getSelfPath('navigation.position');
app.debug('POSITION:' + JSON.stringify(position));
position = position ? position.value : null;
}
const startLine = state.startLine;
app.debug(`processPosition ${JSON.stringify(position)} to ${JSON.stringify(startLine)}`);
if (position && startLine && isNearLineInRoute()) {
// handle the bow offset
const bow = bowPosition(position);
// Get the distance to each end of the line
const toPort = geolib.getPreciseDistance(bow, startLine.port, 0.1);
const toStb = geolib.getPreciseDistance(bow, startLine.stb, 0.1);
// which is the closest end
const closest = toPort < toStb ? startLine.port : startLine.stb;
let toEnd;
let bearingToEnd;
let angle;
let closestEnd;
if (closest === state.startLine.port) {
closestEnd = 'port';
toEnd = toPort;
app.debug('closest to Port(pin):' + toPort);
bearingToEnd = geolib.getRhumbLineBearing(bow, startLine.port);
angle = bearingToEnd - startLine.bearing;
} else {
closestEnd = 'stb';
toEnd = toStb;
app.debug('closest to Stb(boat):' + toStb);
bearingToEnd = geolib.getRhumbLineBearing(bow, startLine.stb);
angle = 180 - (bearingToEnd - startLine.bearing);
}
angle = ((angle + 180) % 360 + 360) % 360 - 180;
const ocs = angle < 0;
// We define a start zone which includes a 45° wedge off each end of the line.
// - If the boat is inside the start zone, distanceToLine = perpendicular to line (or extension).
// - If the boat is outside the start zone, distanceToLine = distance parallel to the line to enter the zone +
// perpendicular distance to the line (or extension).
// This is illustrated for the starboard closest end of the line below:
// \ /
// \ /
// P---------------------------------B - - - - - -x-
// /135 PBz=135\' , VBx .
// / \ ' , .
// / \ ' ,.
// / b z - - - -V
// / \
//
// P=pin; B=boat; x=extension; b=perpToBoat; z=zoneEntry; V=vessel; N=north
// bearing = NBP
// PBz == 135
// bBz == zBx == 45
// Bb == bz == Vx == perpendicular distance to the line or extension.
// PBV = abs(angle)
// VBx = 180 - PBV
// Vx = toEnd * sin (VBx)
// bBV = 90 - VBx
// Vb = toEnd * sin (bBV)
// zb = sqrt((Vx * Vx) / 2)
// Vz = Vb - zb
const anglePBV = Math.abs(angle);
const inStartZone = anglePBV <= 135;
const angleVBx = 180 - anglePBV;
const perpToLineVx = toEnd * Math.sin(toRadians(angleVBx));
let toZoneVz = inStartZone ? 0 : ( toEnd * Math.sin(toRadians(90 - angleVBx)) - Math.sqrt(perpToLineVx * perpToLineVx / 2.0));
const toLine = Math.round( 10 * (toZoneVz + perpToLineVx)) / 10;
const distanceToLine = ocs ? -toLine : toLine;
app.debug('distanceToLine:' + distanceToLine);
state.distanceToLine = distanceToLine;
// Collect VMG samples
const cog = app.getSelfPath("navigation.courseOverGroundTrue")?.value;
const sog = app.getSelfPath("navigation.speedOverGround")?.value;
if (cog != null && sog != null) {
collectVmgSamples(cog, sog, startLine.bearing, toZoneVz, perpToLineVx);
}
const ttl = computeTimeToLine(cog, sog, startLine.bearing, toZoneVz, perpToLineVx, ocs, closestEnd, state.timeToStart ?? 0);
const ttb = !ocs && state.timerRunning && state.timeToStart > 0 && ttl != null ? (state.timeToStart - ttl) : null;
sendDeltas([
{path: 'navigation.racing.distanceStartline', value: distanceToLine},
{path: "navigation.racing.timeToLine", value: ttl},
{path: "navigation.racing.timeToBurn", value: ttb}
]);
} else if (state.distanceToLine) {
state.distanceToLine = null;
sendDeltas([
{path: 'navigation.racing.distanceStartline', value: null},
{path: 'navigation.racing.startLineBias', value: null},
{path: 'navigation.racing.timeToLine', value: null},
{path: 'navigation.racing.timeToBurn', value: null},
]);
}
}
function processWind(twd) {
if (!twd) {
twd = app.getSelfPath('environment.wind.directionTrue');
app.debug('TWD:' + JSON.stringify(twd));
twd = twd ? twd.value : null;
}
if (!twd)
return;
let nextTwa = null;
const activeRoute = state.activeRoute;
if (activeRoute && activeRoute.nextLegHeading) {
nextTwa = (540 + toDegrees(twd) - activeRoute.nextLegHeading) % 360 - 180;
app.debug(`nextTwa: ${nextTwa}`);
}
let bias = null;
const startLine = state.startLine;
if (startLine && isNearLineInRoute()) {
app.debug(`processWind ${twd} to ${JSON.stringify(startLine)}`);
bias = - startLine.length * Math.cos(toRadians(startLine.bearing) - twd);
}
sendDeltas([
{path: 'navigation.racing.stbLineBias', value: bias},
{path: 'navigation.racing.nextLegTrueWindAngle', value: toRadians(nextTwa)},
].filter(entry => entry.value !== null && entry.value !== undefined));
}
function processRoute(activeRoute) {
if (activeRoute) {
// e.g. {"href":"/resources/routes/a12eefe7-8fd3-49f7-81af-ce1f3440d3ff","name":"Course 1","reverse":false,"pointIndex":1,"pointTotal":5}
const nextPointIndex = activeRoute.pointIndex + 1;
if (nextPointIndex < 0 || nextPointIndex === activeRoute.pointTotal) {
activeRoute.nextLegHeading = null;
sendDeltas([
{path: 'navigation.racing.nextLegHeading', value: null},
{path: 'navigation.racing.nextLegTrueWindAngle', value: null}
]);
processPosition();
return;
}
const routeId = activeRoute.href.split('/').pop(); // e.g. "a12eefe7-8fd3-49f7-81af-ce1f3440d3ff"
app.debug('processRoute:' + routeId);
app.resourcesApi.getResource('routes', routeId).then(route => {
app.debug('found active route:', JSON.stringify(route));
// e.g. {
// name:"Course 1",
// description:"Test race course",
// distance:3027,
// feature:{
// type:"Feature",
// geometry:{
// type:"LineString",
// coordinates:[[151.27,-33.80],[151.28,-33.81],[151.27,-33.80],[151.27,-33.80],[151.27,-33.80]]},
// properties:{
// coordinatesMeta:[
// {href:"/resources/waypoints/bb35d9d0-c04e-4721-89f2-a1d8e697ab81"},
// {href:"/resources/waypoints/65082ddd-6fa6-4538-9a01-49d5d795b0bb"},
// {href:"/resources/waypoints/0bbce508-67c6-4f03-8de0-fb0532f88917"},
// {href:"/resources/waypoints/602ee562-c6dc-42c3-a775-512597063ca4"},
// {href:"/resources/waypoints/2e3cf194-8835-498d-abaa-a5d2104550b3"}
// ]
// },
// id:""
// },
// timestamp:"2025-06-09T22:22:54.739Z",
// $source:"resources-provider"}
const coordinates = route.feature.geometry.coordinates;
const fromIndex = activeRoute.reverse ? activeRoute.pointTotal - activeRoute.pointIndex - 1 : activeRoute.pointIndex;
const toIndex = activeRoute.reverse ? activeRoute.pointTotal - nextPointIndex - 1 : nextPointIndex;
const [fromLongitude, fromLatitude] = coordinates[fromIndex];
const [toLongitude, toLatitude] = coordinates[toIndex];
app.debug(`from: {longitude:${fromLongitude}, latitude:${fromLatitude}}, to: {longitude:${toLongitude}, latitude:${toLatitude}}`);
const nextLegHeading = geolib.getRhumbLineBearing(
{latitude: fromLatitude, longitude: fromLongitude},
{latitude: toLatitude, longitude: toLongitude});
app.debug(`nextLegHeading: ${nextLegHeading}`);
activeRoute.nextLegHeading = nextLegHeading;
state.activeRoute = activeRoute;
sendDeltas([
{path: 'navigation.racing.nextLegHeading', value: toRadians(nextLegHeading)},
{path: 'navigation.racing.nextLegTrueWindAngle', value: null}
]);
processWind();
}).catch(err => {
app.error(err);
});
} else {
state.activeRoute = null;
sendDeltas([
{path: 'navigation.racing.nextLegHeading', value: null},
{path: 'navigation.racing.nextLegTrueWindAngle', value: null}
]);
processPosition(null);
}
}
function getStartLineOptions() {
app.debug("getStartLineOptions for ", state.startLineName);
for (const lineOption of state.options.lines) {
if (lineOption.startLineName === state.startLineName) {
app.debug("lineOption: ", JSON.stringify(lineOption));
return lineOption;
}
}
app.debug("lineOption: ", JSON.stringify(state.options));
return state.options;
}
function getStartLine() {
const stbEnd = app.getSelfPath(`navigation.racing.startLineStb`);
const portEnd = app.getSelfPath(`navigation.racing.startLinePort`);
if (stbEnd && stbEnd.value && portEnd && portEnd.value) {
let startLine = { stb: stbEnd.value, port: portEnd.value};
startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1);
startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port);
state.startLine = startLine;
app.debug('found startLine ', JSON.stringify(startLine));
return startLine;
}
return null;
}
// Return the plugin
return {
id: 'signalk-racer',
name: 'SignalK Racer',
start: (options) => {
if (!options.startLinePort || !options.startLineStb) {
app.error('Missing waypoint names: startLinePort and/or startLineStb not configured.');
return;
}
initRacer({
minSog: options.minSog ?? 1.0,
maxDistance: options.maxDistance ?? 2000,
maxSamples: options.maxSamples ?? 600,
percentile: options.percentile ?? 0.9
}, app);
app.debug('startLinePort:' + options.startLinePort);
app.debug('startLineStb:' + options.startLineStb);
app.debug('app.selfId:' + app.selfId);
let fromBow = app.getSelfPath('sensors.gps.fromBow');
let fromCenter = app.getSelfPath('sensors.gps.fromCenter');
state.options = options;
state.wayPointsScanned = false;
state.gpsFromBow = fromBow ? fromBow.value : null;
state.gpsFromCenter = fromCenter ? fromCenter.value : null;
state.timeToStart = options.timer;
state.startLine = getStartLine();
publishLineList();
// Is the timer already started
{
const startTime = app.getSelfPath('navigation.racing.startTime');
if (startTime && startTime.value) {
state.startTime = startTime.value;
const now = Date.now();
const startAtTime = new Date(startTime.value);
if (startAtTime > now) {
state.timeToStart = Math.round((new Date(state.startTime) - now) / 1000);
state.timerRunning = true;
} else {
state.timeToStart = 0;
state.timerRunning = false;
}
}
}
sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]);
// Subscribe to position updates.
app.subscriptionmanager.subscribe(
{
context: 'vessels.' + app.selfId,
subscribe: [
{
path: 'navigation.position',
period: options.period,
}
]
},
unsubscribes,
(subscriptionError) => {
app.error('Error:' + subscriptionError)
},
(delta) => {
app.debug('DELTA POSITIONS ' + JSON.stringify(delta));
let position = null;
delta.updates.forEach((u) => {
u.values.forEach((v) => {
app.debug('DELTA POSITION ' + v.path + ' = ' + JSON.stringify(v.value));
position = v.value;
})
})
if (state.startLine)
processPosition(position);
else
findLineAndThenProcess(position, false);
if (!state.initActiveRoute) {
state.initActiveRoute = true;
const activeRoute = app.getSelfPath('navigation.course.activeRoute');
if (activeRoute)
processRoute(activeRoute.value);
}
}
)
// Subscribe to waypoint updates.
app.subscriptionmanager.subscribe(
{
context: 'vessels.' + app.selfId,
subscribe: [
{
path: 'resources.waypoints.*',
policy: 'instant'
}
]
},
unsubscribes,
(subscriptionError) => {
app.error('Error:' + subscriptionError)
},
(delta) => {
app.debug('DELTAS WAYPOINTS ' + JSON.stringify(delta));
delta.updates.forEach((u) => {
u.values.forEach((v) => {
app.debug("DELTA WAYPOINT: " + v.path + ' = ' + JSON.stringify(v.value))
})
});
state.wayPointsScanned = false;
findLineAndThenProcess(undefined, true);
}
)
// Subscribe to TWD updates.
app.subscriptionmanager.subscribe(
{
context: 'vessels.' + app.selfId,
subscribe: [
{
path: 'environment.wind.directionTrue',
period: options.period,
}
]
},
unsubscribes,
(subscriptionError) => {
app.error('Error:' + subscriptionError)
},
(delta) => {
let twd;
app.debug('DELTAS TWD ' + JSON.stringify(delta));
delta.updates.forEach((u) => {
u.values.forEach((v) => {
twd = v.value;
app.debug("DELTA TWD: " + v.path + ' = ' + twd)
})
});
processWind(twd);
}
);
// Subscribe to Active route.
app.subscriptionmanager.subscribe(
{
context: 'vessels.' + app.selfId,
subscribe: [
{
path: 'navigation.course.activeRoute',
policy: 'instant'
}
]
},
unsubscribes,
(subscriptionError) => {
app.error('Error:' + subscriptionError)
},
(delta) => {
let route;
app.debug('DELTAS ACTIVE ROUTE ' + JSON.stringify(delta));
delta.updates.forEach((u) => {
u.values.forEach((v) => {
route = v.value;
app.debug("DELTA ACTIVE ROUTE: " + v.path + ' = ' + JSON.stringify(route));
})
});
processRoute(route);
}
);
// Subscribe to racing start line to see changes from any other plugin.
app.subscriptionmanager.subscribe(
{
context: 'vessels.' + app.selfId,
subscribe: [
{path: 'navigation.racing.startLinePort', policy: 'instant'},
{path: 'navigation.racing.startLineStb', policy: 'instant'},
]
},
unsubscribes,
(subscriptionError) => {
app.error('Error:' + subscriptionError)
},
(delta) => {
app.debug('DELTAS START LINE ENDS ' + JSON.stringify(delta));